diff --git a/package-lock.json b/package-lock.json index 8592c67..3eadda3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "membership-mixin": "git+https://github.com/FLYBYME/membership-mixin.git", "middlewares": "git+https://github.com/PaaS-Shack/middlewares.git", "moleculer": "^0.14.26", + "native-dns-packet": "^0.1.1", "nats": "^2.7.1", "whoiser": "^1.17.1" }, @@ -1757,6 +1758,14 @@ "node": ">=4" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2020,6 +2029,17 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffercursor": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz", + "integrity": "sha512-Z+6Jm/eW6ITeqcFQKVXX7LYIGk7rENqCKHJ4CbWfJMeLpQZJj1v70WehkLmp+1kFN/QyCgpQ3Z0dKUHAwSbf9w==", + "dependencies": { + "verror": "^1.4.0" + }, + "engines": { + "node": ">= 0.5.0" + } + }, "node_modules/cacache": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", @@ -2321,6 +2341,11 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "node_modules/cron": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz", @@ -2985,6 +3010,14 @@ "type": "^2.7.2" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ] + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5204,6 +5237,18 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/native-dns-packet": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/native-dns-packet/-/native-dns-packet-0.1.1.tgz", + "integrity": "sha512-j1XxnFFTUB7mujma468WyAOmyVtkuuLTelxJF13tSTIPO56X7bHALrG0G4jFQnvyTPCt4VnFiZezWpfKbaHc+g==", + "dependencies": { + "buffercursor": ">= 0.0.12", + "ipaddr.js": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.5.0" + } + }, "node_modules/nats": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/nats/-/nats-2.17.0.tgz", @@ -6770,6 +6815,19 @@ "node": ">= 8" } }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -8382,6 +8440,11 @@ } } }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + }, "astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -8567,6 +8630,14 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "buffercursor": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz", + "integrity": "sha512-Z+6Jm/eW6ITeqcFQKVXX7LYIGk7rENqCKHJ4CbWfJMeLpQZJj1v70WehkLmp+1kFN/QyCgpQ3Z0dKUHAwSbf9w==", + "requires": { + "verror": "^1.4.0" + } + }, "cacache": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", @@ -8791,6 +8862,11 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "cron": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz", @@ -9332,6 +9408,11 @@ "type": "^2.7.2" } }, + "extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10907,6 +10988,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "native-dns-packet": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/native-dns-packet/-/native-dns-packet-0.1.1.tgz", + "integrity": "sha512-j1XxnFFTUB7mujma468WyAOmyVtkuuLTelxJF13tSTIPO56X7bHALrG0G4jFQnvyTPCt4VnFiZezWpfKbaHc+g==", + "requires": { + "buffercursor": ">= 0.0.12", + "ipaddr.js": ">= 0.1.1" + } + }, "nats": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/nats/-/nats-2.17.0.tgz", @@ -12070,6 +12160,16 @@ } } }, + "verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index e8ef291..601421a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "membership-mixin": "git+https://github.com/FLYBYME/membership-mixin.git", "middlewares": "git+https://github.com/PaaS-Shack/middlewares.git", "moleculer": "^0.14.26", + "native-dns-packet": "^0.1.1", "nats": "^2.7.1", "whoiser": "^1.17.1" }, diff --git a/services/dohs.service.js b/services/dohs.service.js new file mode 100644 index 0000000..1fca99b --- /dev/null +++ b/services/dohs.service.js @@ -0,0 +1,487 @@ + + +const Lock = require('../lib/lock') + + +const DbService = require("db-mixin"); +const Cron = require("cron-mixin"); + +const { MoleculerClientError } = require("moleculer").Errors; + +const https = require('https'); + +const Packet = require('native-dns-packet'); + + +/** + * attachments of addons service + */ +module.exports = { + name: "utils.dohs", + version: 1, + + mixins: [ + DbService({ + Permissions: 'utils.dohs', + }), + Cron + ], + + /** + * Service dependencies + */ + dependencies: [ + + ], + + /** + * Service settings + */ + settings: { + + fields: { + + + key: { + type: "string", + required: true, + immutable: true, + lowercase: true, + trim: true, + empty: false, + }, + fqdn: { + type: "string", + required: true, + immutable: true, + lowercase: true, + trim: true, + empty: false, + }, + name: { + type: "string", + required: true, + immutable: true, + lowercase: true, + trim: true, + empty: false, + }, + + typeStr: { + type: "enum", + values: ["A", "AAAA", "CNAME", "SOA", "MX", "NS", "TXT", "CAA", "SRV"], + immutable: true, + required: true, + }, + type: { + type: "number", + required: true, + }, + class: { + type: "number", + required: true, + }, + provider: { + type: "string", + required: true, + }, + data: { + type: "string", + required: true, + }, + + replace: { + type: "string", + required: false, + }, + + ttl: { + type: "number", + default: 99, + set: ({ params }) => { + return params.ttl > 2500 ? 2500 : params.ttl + }, + required: false, + }, + expires: { + type: "number", + set: ({ params }) => { + return Date.now() + (params.ttl * 1000) + }, + required: false, + }, + priority: { + type: "number", + default: 5, + required: false, + }, + + weight: { + type: "number", + required: false, + }, + port: { + type: "number", + required: false, + }, + target: { + type: "string", + required: false, + }, + + flag: { + type: "number", + default: 0, + required: false, + }, + tag: { + type: "string", + required: false, + }, + + admin: { + type: "string", + required: false, + }, + serial: { + type: "number", + required: false, + }, + refresh: { + type: "number", + required: false, + }, + retry: { + type: "number", + required: false, + }, + expiration: { + type: "number", + required: false, + }, + minimum: { + type: "number", + required: false, + }, + + ...DbService.FIELDS,// inject dbservice fields + }, + defaultPopulates: [], + + scopes: { + ...DbService.SCOPE, + }, + + defaultScopes: [ + ...DbService.DSCOPE, + ], + + // default init config settings + config: { + + }, + + + providers: { + 'google': { + ip: '8.8.8.8', + path: '/dns-query', + domain: 'dns.google' + }, + 'cloudflare': { + ip: '104.16.249.249', + path: '/dns-query', + domain: 'cloudflare-dns.com' + }, + 'cleanbrowsing': { + ip: '185.228.168.10', + path: '/doh/family-filter', + domain: 'doh.cleanbrowsing.org' + } + } + }, + + + crons: [ + { + name: "ClearExpiredRecords", + cronTime: "* * * * *", + onTick: { + action: "v1.utils.dohs.clearExpired" + } + } + ], + /** + * Actions + */ + + actions: { + clearExpired: { + params: { + + }, + async handler(ctx) { + return this.clearExpired(ctx) + } + }, + resolveProvider: { + params: { + fqdn: { type: "string", optional: false }, + type: { type: "enum", values: ["A", "AAAA", "CNAME", "SOA", "MX", "NS", "TXT", "SRV"], default: 'A', optional: true }, + provider: { type: "enum", values: ["google", "cloudflare"], default: 'cloudflare', optional: true }, + cache: { type: "boolean", default: true, optional: true }, + }, + async handler(ctx) { + const params = Object.assign({}, ctx.params); + return this.resolve(params.fqdn, params.type, params.provider).then((res) => res.map((r) => ({ ...r, data: r.data || r.address }))); + } + }, + findQuery: { + params: { + + }, + async handler(ctx) { + const params = Object.assign({}, ctx.params); + return this.findEntities(null, { + query: { + queryStr: params.queryStr + } + }); + } + }, + query: { + params: { + fqdn: { type: "string", optional: false }, + type: { type: "enum", values: ["A", "AAAA", "CNAME", "SOA", "MX", "NS", "TXT", "SRV"], default: 'A', optional: true }, + provider: { type: "enum", values: ["google", "cloudflare"], default: 'cloudflare', optional: true }, + cache: { type: "boolean", default: true, optional: true }, + }, + async handler(ctx) { + const params = Object.assign({}, ctx.params); + + const nodeID = ctx.nodeID.split('-').shift() + + const start = Date.now(); + const key = `${params.fqdn}.${params.type}` + + if (!params.cache) { + const results = await this.actions.resolveProvider({ ...params }, { parentCtx: ctx }); + //console.log(results) + return results + } + + await this.lock.acquire(key); + + const results = await this.findEntities(ctx, { + query: { + fqdn: params.fqdn, + typeStr: params.type, + provider: params.provider, + } + }); + if (results.length > 0) { + + this.lock.release(key) + this.log(nodeID, key, start, true, results); + + return this.mapRecords(results); + } + + + const query = await this.actions.resolveProvider({ ...params }, { parentCtx: ctx }); + + for (let index = 0; index < query.length; index++) { + const element = query[index]; + results.push(await this.actions.create({ + ...element, + fqdn: params.fqdn, + typeStr: params.type, + provider: params.provider, + key: key + }, { parentCtx: ctx })); + } + + this.lock.release(key); + + this.log(nodeID, key, start, false, results) + + + return this.mapRecords(results); + } + } + }, + + /** + * Events + */ + events: { + + }, + + /** + * Methods + */ + methods: { + + log(nodeID, key, start, hitOrMiss, results) { + this.logger.info(`${nodeID} ${key} ${(Date.now() - start)}ms ${hitOrMiss ? 'HIT' : 'MISS'}`, results.map((a) => a.address || a.data)); + }, + + mapRecords(records) { + return records.map((record) => { + record.ttl = Math.ceil((record.expires - Date.now()) / 1000) + if (record.ttl < 0) record.ttl = 0 + return record; + }) + }, + getDomainType(domainType) { + let type = 0 + switch (domainType.toUpperCase()) { + case 'A': + type = 1 + break + case 'AAAA': + type = 28 + break + case 'CAA': + type = 257 + break + case 'CNAME': + type = 5 + break + case 'DS': + type = 43 + break + case 'DNSKEY': + type = 48 + break + case 'MX': + type = 15 + break + case 'NS': + type = 2 + break + case 'NSEC': + type = 47 + break + case 'NSEC3': + type = 50 + break + case 'RRSIG': + type = 46 + break + case 'SOA': + type = 6 + break + case 'TXT': + type = 16 + break + case 'SRV': + type = 0x21 + break + default: + // A + type = 1 + break + } + return type + }, + newBuffer(length) { + let buf + if (Buffer.alloc) { + buf = Buffer.alloc(length) + } else { + buf = new Buffer(length) + } + return buf + }, + resolve(name, domainType, provider) { + + let type = this.getDomainType(domainType); + let dnsPacket = new Packet(); + let dnsBuf = this.newBuffer(128); + + dnsPacket.question.push({ + name, type, + class: 1 + }) + Packet.write(dnsBuf, dnsPacket) + + const providerPath = this.settings.providers[provider].path; + const providerIP = this.settings.providers[provider].ip; + const providerDomain = this.settings.providers[provider].domain; + + const path = `${providerPath}?dns=${dnsBuf.toString('base64').replace(/=+/, '')}` + + const options = { + hostname: providerIP, + port: 443, + path, + method: 'GET', + headers: { + 'Accept': 'application/dns-message', + 'Content-type': 'application/dns-message', + 'Host': providerDomain + } + }; + + return new Promise(function (resolve, reject) { + const req = https.request(options, (res) => { + + const chunks = [] + + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + + res.on('error', (err) => reject(err)); + + res.on('end', () => { + const response = Buffer.concat(chunks) + if (res.statusCode === 200) { + try { + let dnsResult = Buffer.from(response) + let result = Packet.parse(dnsResult) + return resolve(result.answer) + } catch (err) { + this.logger.error(`Failed to parse dns packet, provider: ${provider}, xhr status: ${res.statusCode}.`, err); + } + } else { + this.logger.error(`Cannot find the domain, provider: ${provider}, status: ${res.statusCode}.`); + } + resolve([]) + }); + }); + + req.on('error', (err) => resolve([])); + req.end(); + }) + }, + async clearExpired(ctx) { + const adapter = await this.getAdapter(ctx); + const removed = await adapter.removeMany({ + expires: { $lte: Date.now() } + }); + if (removed > 0) { + const count = await this.countEntities(ctx); + this.logger.info(`Expired records. Removed ${removed} of ${count + removed}`); + } + } + }, + + /** + * Service created lifecycle event handler + */ + created() { + + this.lock = new Lock(); + }, + + /** + * Service started lifecycle event handler + */ + started() { }, + + /** + * Service stopped lifecycle event handler + */ + stopped() { } +}; \ No newline at end of file