diff --git a/lib/handover.js b/lib/handover.js index 5adb7c6..e15ed5f 100644 --- a/lib/handover.js +++ b/lib/handover.js @@ -8,6 +8,7 @@ const {wire, util} = require('bns'); const {BufferReader} = require('bufio'); const Ethereum = require('./ethereum'); +const Sia = require('./sia'); const plugin = exports; @@ -23,6 +24,11 @@ class Plugin { projectSecret: node.config.str('handover-infura-projectsecret') }); + this.sia = new Sia({ + logger: node.logger.context('handover:sia'), + portal: node.config.str('handover-sia-portal', 'siasky.net') // or skyportal.xyz, etc. + }); + // Plugin can not operate if node doesn't have DNS resolvers if (!this.ns || !this.node.rs) return; @@ -66,6 +72,23 @@ class Plugin { return this.sendSOA(name); } break; + + case '_sia.': + if (type === wire.types.DS) { + // TODO: plugin should have ephemeral DNSSEC keys. + // We should return that here, signed by HNS root ZSK. + } else { + return this.sendSOA(name); + } + break; + case '_siaregistry.': + if (type === wire.types.DS) { + // TODO: plugin should have ephemeral DNSSEC keys. + // We should return that here, signed by HNS root ZSK. + } else { + return this.sendSOA(name); + } + break; } // Next, try actually resolving the name with the HNS root zone. @@ -85,7 +108,7 @@ class Plugin { // and query it for the user's original request if (rr.data.ns.slice(-5) === '.eth.') { // If the recursive is being minimal, don't look up the name. - // Send the SOA back and get the full query from the recursive . + // Send the SOA back and get the full query from the recursive. if (labels.length < 2) { return this.sendSOA(name); } @@ -107,7 +130,7 @@ class Plugin { // the user's original request if (rr.data.ns.slice(-6) === '._eth.') { // If the recursive is being minimal, don't look up the name. - // Send the SOA back and get the full query from the recursive . + // Send the SOA back and get the full query from the recursive. if (labels.length < 2) { return this.sendSOA(name); } @@ -124,14 +147,55 @@ class Plugin { rr.data.ns ); } + + // Resolve DNS by looking up the zone file at the specified + // skylink and query it for the user's original request + if (rr.data.ns.slice(-6) === '._sia.') { + if (labels.length < 1) { + return this.sendSOA(name); + } + this.logger.debug( + 'Intercepted referral to ._sia: %s %s -> %s NS: %s', + name, + wire.typesByVal[type], + rr.name, + rr.data.ns + ); + data = await this.sia.resolveDnsFromSkylink( + name, + type, + rr.data.ns + ); + } + + // Resolve DNS by looking up the specified sia registry entry + // for the zone file skylink address, then query the zone file + // for the user's original request + if (rr.data.ns.slice(-14) === '._siaregistry.') { + if (labels.length === 0) { + return this.sendSOA(name); + } + this.logger.debug( + 'Intercepted referral to ._siaregistry: %s %s -> %s NS: %s', + name, + wire.typesByVal[type], + rr.name, + rr.data.ns + ); + data = await this.sia.resolveDnsFromRegistry( + name, + type, + rr.data.ns + ); + } } - // If the Ethereum stuff came up empty, return the + // If the above scheme resolvers came up empty, return the // HNS root server response unmodified. if (!data || data.length === 0) return res; - // If we did get an answer from Ethereum, mark the response + // If we did get an answer from the resolvers, mark the response // as authoritative and insert the new answer. this.logger.debug('Returning answers from alternate naming system'); const replacementRes = new wire.Message(); @@ -141,7 +205,7 @@ class Plugin { replacementRes.answer.push(wire.Record.read(br)); } - // Answers resolved from Ethereum appear to come directly + // Answers resolved from the resolvers appear to come directly // from the HNS root zone. this.ns.signRRSet(replacementRes.answer, type); diff --git a/lib/sia.js b/lib/sia.js new file mode 100644 index 0000000..faf2f9d --- /dev/null +++ b/lib/sia.js @@ -0,0 +1,147 @@ +'use strict'; + +const axios = require('axios').default; +const { Zone } = require('bns'); +const LRU = require('blru'); + +class Sia { + constructor({ logger, portal }) { + this.logger = logger; + this.cache = new SiaCache(); + + const match = portal.match(/(?:https?\:\/\/)?([\w\.\-_]+)\/?/i); + if (match) this.portal = match[1]; + else this.logger.error('Invalid portal.'); + } + + async resolveDnsFromSkylink(name, type, ns, node) { + this.logger.debug('Resolving', name, type, ns); + if (!node) node = name; + + const labels = ns.split('.'); + + if (labels.length !== 3) return null; + if (labels[1] !== '_sia') return null; + + const skylink = labels[0]; + if (skylink.length !== 46) return null; + + const skylinkContent = await this.getSkylinkContent(skylink); + return this.resolveDnsFromZone(skylinkContent, name, type); + } + + async resolveDnsFromRegistry(name, type, ns, node) { + this.logger.debug('Resolving from Registry: ', name, type, ns); + if (!node) node = name; + + const labels = ns.split('.'); + + if (labels.length !== 7) return null; + if (labels[5] !== '_siaregistry') return null; + + // Pubkey and datakey are split into 2 labels of 32 bytes each because of max label length + const algo = labels[0]; + const pubKey = labels[1] + labels[2]; + const dataKey = labels[3] + labels[4]; + if (algo.length <= 0 || pubKey.length !== 64 || dataKey.length !== 64) + return null; + + const registryData = await this.getRegistryEntry(algo, pubKey, dataKey); + const skylinkContent = await this.getSkylinkContent(registryData); + return this.resolveDnsFromZone(skylinkContent, name, type); + } + + async getSkylinkContent(skylink) { + const item = this.cache.get(skylink); + if (item) return item; + + const skylinkContent = await this._getSkylinkContent(skylink); + this.cache.set(skylink, skylinkContent); + return skylinkContent; + } + + async _getSkylinkContent(skylink) { + this.logger.debug('Fetching skylink...', skylink); + const content = await axios({ + url: `https://${this.portal}/${skylink}`, + responseType: 'text', + transformResponse: [ + (data) => { + return data; + }, + ], // force text response: https://github.com/axios/axios/issues/907 + maxBodyLength: 20000, // in bytes + }); + this.logger.spam(content.data); + return content.data; + } + + async getRegistryEntry(algo, pubKey, dataKey) { + const key = `${pubKey};${dataKey}`; + const item = this.cache.get(key); + if (item) return item; + + const registryEntry = await this._getRegistryEntry(algo, pubKey, dataKey); + this.cache.set(key, registryEntry); + return registryEntry; + } + + async _getRegistryEntry(algo, pubKey, dataKey) { + this.logger.debug('Fetching sia registry...', algo, pubKey, dataKey); + const content = await axios({ + url: `https://${this.portal}/skynet/registry?publickey=${algo}:${pubKey}&datakey=${dataKey}`, + maxBodyLength: 20000, // in bytes + }); + this.logger.spam(content.data); + return Buffer.from(content.data.data, 'hex').toString(); + } + + async resolveDnsFromZone(zoneContent, name, type) { + const zone = new Zone(); + + try { + zone.fromString(zoneContent); + } catch (error) { + if (error.type !== 'ParseError') this.logger.error(error); + return null; + } + + this.logger.spam(zone); + const records = zone.get(name, type).map((rec) => rec.toRaw()); + if (!records.length) return null; + + return Buffer.concat(records); + } +} + +class SiaCache { + constructor(size = 3000) { + this.cache = new LRU(size); + this.CACHE_TTL = 30 * 60 * 1000; + } + + set(key, data) { + this.cache.set(key, { + time: Date.now(), + data, + }); + + return this; + } + + get(key) { + const item = this.cache.get(key); + + if (!item) return null; + + if (Date.now() > item.time + this.CACHE_TTL) return null; + + return item.data; + } + + reset() { + this.cache.reset(); + } +} + +module.exports = Sia; diff --git a/package-lock.json b/package-lock.json index ee3dab0..bac9bec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -385,11 +385,27 @@ "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", "integrity": "sha1-4h3xCtbCBTKVvLuNq0Cwnb6ofk0=" }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "bech32": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" }, + "blru": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/blru/-/blru-0.1.6.tgz", + "integrity": "sha512-34+xZ2u4ys/aUzWCU9m6Eee4nVuN1ywdxbi8b3Z2WULU6qvnfeHvCWEdGzlVfRbbhimG2xxJX6R77GD2cuVO6w==", + "requires": { + "bsert": "~0.0.10" + } + }, "bmocha": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/bmocha/-/bmocha-2.1.5.tgz", @@ -406,6 +422,11 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, + "bsert": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/bsert/-/bsert-0.0.10.tgz", + "integrity": "sha512-NHNwlac+WPy4t2LoNh8pXk8uaIGH3NSaIUbTTRXGpE2WEbq0te/tDykYHkFK57YKLPjv/aGHmbqvnGeVWDz57Q==" + }, "bufio": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/bufio/-/bufio-1.0.7.tgz", @@ -462,6 +483,11 @@ "@ethersproject/wordlists": "5.0.9" } }, + "follow-redirects": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz", + "integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==" + }, "hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", diff --git a/package.json b/package.json index 812aa81..9efec2a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ }, "homepage": "https://github.com/imperviousinc/handover#readme", "dependencies": { + "axios": "^0.21.1", + "blru": "^0.1.6", "bufio": "^1.0.7", "ethers": "^5.0.31" },