diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js index c13217e98d4..9580c6e4ebb 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js @@ -1,5 +1,4 @@ const sample = (arr) => arr[Math.floor(Math.random() * arr.length)]; -const networks = require('@dashevo/dashcore-lib/lib/networks'); class ListDAPIAddressProvider { /** @@ -20,28 +19,11 @@ class ListDAPIAddressProvider { * @returns {Promise} */ async getLiveAddress() { - const liveAddresses = this.getLiveAddresses(); - - const liveAddress = sample(liveAddresses); - - if (liveAddress === undefined) { - return undefined; - } - - // This is a temporary fix for a localhost masternode. - // On macOS, internal docker IP is used to register masternode, and it's - // not really possible to bind to that address, so that workaround is introduced. - const network = networks.get(this.options.network); - if (network && network.regtestEnabled) { - const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length); - - liveAddress.protocol = 'https'; - liveAddress.host = '127.0.0.1'; - liveAddress.allowSelfSignedCertificate = true; - liveAddress.port = 2443 + randomNodeIndex * 100; - } - - return liveAddress; + // Addresses are returned as configured. The regtest compatibility rewrite + // for masternode-list addresses lives in + // SimplifiedMasternodeListDAPIAddressProvider, so an explicit list is + // never rewritten. + return sample(this.getLiveAddresses()); } /** diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js index c6607e843eb..c4e615d980e 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js @@ -1,3 +1,5 @@ +const networks = require('@dashevo/dashcore-lib/lib/networks'); + const DAPIAddress = require('./DAPIAddress'); class SimplifiedMasternodeListDAPIAddressProvider { @@ -5,9 +7,13 @@ class SimplifiedMasternodeListDAPIAddressProvider { * @param {SimplifiedMasternodeListProvider} smlProvider * @param {ListDAPIAddressProvider} listDAPIAddressProvider * @param {DAPIAddress[]} addressWhiteList + * @param {DAPIClientOptions} [options] - defaults to the list provider's options */ - constructor(smlProvider, listDAPIAddressProvider, addressWhiteList) { + constructor(smlProvider, listDAPIAddressProvider, addressWhiteList, options) { this.smlProvider = smlProvider; + // A caller of the original three-argument constructor still gets the + // regtest rewrite: the list provider holds the same client options. + this.options = options ?? listDAPIAddressProvider.options ?? {}; this.listDAPIAddressProvider = listDAPIAddressProvider; this.addressWhiteStrings = addressWhiteList.map((dapiAddress) => dapiAddress.toString()); } @@ -34,7 +40,28 @@ class SimplifiedMasternodeListDAPIAddressProvider { addressesByRegProTxHashes[address.getProRegTxHash()] = address; }); - const updatedAddresses = validMasternodeList.map((smlEntry) => { + // This is a temporary fix for a localhost masternode. + // On macOS, internal docker IP is used to register masternode, and it's + // not really possible to bind to that address, so that workaround is introduced. + // + // It applies here, and only here, because only addresses discovered from + // the masternode list can hold such an unreachable docker-internal host. An + // address list the caller supplies (`dapiAddresses`, `seeds`) already names + // the exact gateway to talk to (dashmate e2e suites move the stock ports on + // purpose), and clobbering it with the stock local ports silently redirects + // every request to whichever network squats those ports on the machine. + // + // The discovered pool is rewritten before it is published, not the address + // this method returns: the masternode list stream selects from the same + // list provider directly on every (re)connect. + const network = networks.get(this.options.network); + const isRegtest = Boolean(network && network.regtestEnabled); + + // Each masternode's gateway port follows its position in the full list, so + // white-listing some masternodes does not move the others to other ports. + const gatewayPorts = new Map(); + + const updatedAddresses = validMasternodeList.map((smlEntry, index) => { let address = addressesByRegProTxHashes[smlEntry.proRegTxHash]; if (!address) { @@ -46,8 +73,15 @@ class SimplifiedMasternodeListDAPIAddressProvider { }); } else { address.setHost(smlEntry.getIp()); + + if (isRegtest) { + // Undo the last rewrite, so the white list sees the registered endpoint + address.setPort(smlEntry.platformHTTPPort); + } } + gatewayPorts.set(address, 2443 + index * 100); + return address; }); @@ -58,6 +92,17 @@ class SimplifiedMasternodeListDAPIAddressProvider { )); } + if (isRegtest) { + filteredAddresses.forEach((address) => { + /* eslint-disable no-param-reassign */ + address.protocol = 'https'; + address.host = '127.0.0.1'; + address.allowSelfSignedCertificate = true; + address.port = gatewayPorts.get(address); + /* eslint-enable no-param-reassign */ + }); + } + this.listDAPIAddressProvider.setAddresses(filteredAddresses); return this.listDAPIAddressProvider.getLiveAddress(); diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js b/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js index 5a02a00b58d..8e767f4ef27 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js @@ -94,6 +94,7 @@ function createDAPIAddressProviderFromOptions(options) { smlProvider, listDAPIAddressProvider, dapiAddressesWhiteList.map((rawAddress) => new DAPIAddress(rawAddress)), + options, ); } diff --git a/packages/js-dapi-client/lib/networkConfigs.js b/packages/js-dapi-client/lib/networkConfigs.js index e45953ac3a1..293ecd17240 100644 --- a/packages/js-dapi-client/lib/networkConfigs.js +++ b/packages/js-dapi-client/lib/networkConfigs.js @@ -40,7 +40,9 @@ module.exports = { ], }, local: { - dapiAddresses: ['127.0.0.1'], + // The local dashmate gateway: stock port, self-signed TLS + // (see scripts/configure_test_suite.sh). + dapiAddresses: ['127.0.0.1:2443:self-signed'], network: 'regtest', }, mainnet: { diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js index 8b887a4ad78..6ad52d20a30 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js @@ -100,20 +100,72 @@ describe('ListDAPIAddressProvider', () => { expect(address).to.be.undefined(); }); - it('should return modified address for localhost node', async () => { + it('should not modify an explicit address that carries a proRegTxHash', async () => { options = { network: 'local', }; + // The regtest rewrite belongs to the masternode-list provider. A list + // the caller supplies is used as given, even when an entry names the + // masternode it belongs to. + const explicitAddress = new DAPIAddress({ + host: '127.0.0.2', + port: 45003, + proRegTxHash: 'a'.repeat(64), + }); + listDAPIAddressProvider = new ListDAPIAddressProvider( - addresses, + [explicitAddress], + options, + ); + + const liveAddress = await listDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.host).to.equal('127.0.0.2'); + expect(liveAddress.port).to.equal(45003); + expect(liveAddress.allowSelfSignedCertificate).to.be.false(); + }); + + it('should not modify a caller-supplied non-loopback address', async () => { + options = { + network: 'local', + }; + + // A caller-supplied address names the exact gateway to talk to, even + // when the host is a secondary loopback, LAN IP, or container hostname. + const explicitAddress = new DAPIAddress('127.0.0.2:45003:self-signed'); + + listDAPIAddressProvider = new ListDAPIAddressProvider( + [explicitAddress], + options, + ); + + const liveAddress = await listDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.host).to.equal('127.0.0.2'); + expect(liveAddress.port).to.equal(45003); + expect(liveAddress.allowSelfSignedCertificate).to.be.true(); + }); + + it('should not modify an explicitly configured loopback address', async () => { + options = { + network: 'local', + }; + + // A local network that moved its ports off the stock 2443 range + // (dashmate e2e suites do) is addressed explicitly; rewriting the port + // would redirect every request to whatever squats the stock ports. + const loopbackAddress = new DAPIAddress('127.0.0.1:45003:self-signed'); + + listDAPIAddressProvider = new ListDAPIAddressProvider( + [loopbackAddress], options, ); const liveAddress = await listDAPIAddressProvider.getLiveAddress(); expect(liveAddress.host).to.equal('127.0.0.1'); - expect(liveAddress.protocol).to.equal('https'); + expect(liveAddress.port).to.equal(45003); expect(liveAddress.allowSelfSignedCertificate).to.be.true(); }); }); diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js index f130795e44e..ec5f5c6c348 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js @@ -3,6 +3,7 @@ const SimplifiedMNListEntry = require('@dashevo/dashcore-lib/lib/deterministicmn const DAPIAddress = require('../../../lib/dapiAddressProvider/DAPIAddress'); const SimplifiedMasternodeListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider'); +const ListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/ListDAPIAddressProvider'); describe('SimplifiedMasternodeListDAPIAddressProvider', () => { let smlDAPIAddressProvider; @@ -11,9 +12,10 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { let smlMock; let validMasternodeList; let addresses; + let mnListDiffFixture; beforeEach(function beforeEach() { - const mnListDiffFixture = [{ + mnListDiffFixture = [{ proRegTxHash: 'f5ec54aed788c434da2fc535ea6b125ec6fc54e58bc0a00a005d1a8d5e477a90', confirmedHash: '53125505b0e9d11b371cf3e12c92d164296dfa215fde6201d28ea44bed992187', service: '192.168.65.2:20101', @@ -196,6 +198,172 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { }); }); + describe('#getLiveAddress on a regtest network', () => { + const options = { network: 'local' }; + + it('should rewrite a masternode-list address to the local gateway', async () => { + // The fixture masternodes registered a docker-internal IP, which the + // host cannot reach on macOS. + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + new ListDAPIAddressProvider([], options), + [], + options, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getProRegTxHash()).to.be.oneOf( + validMasternodeList.map((smlEntry) => smlEntry.proRegTxHash), + ); + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.be.oneOf([2443, 2543, 2643]); + expect(liveAddress.getProtocol()).to.equal('https'); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + + // The masternode list stream selects from the underlying list provider on + // every (re)connect, without going through this provider. + it('should publish only rewritten addresses to the underlying list provider', async () => { + const listDAPIAddressProvider = new ListDAPIAddressProvider([], options); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + listDAPIAddressProvider, + [], + options, + ); + + const expectGatewayPool = () => { + const pool = listDAPIAddressProvider.getAllAddresses(); + + expect(pool.map((address) => address.toJSON())).to.deep.equal( + validMasternodeList.map((smlEntry, index) => ({ + host: '127.0.0.1', + port: 2443 + index * 100, + protocol: 'https', + allowSelfSignedCertificate: true, + proRegTxHash: smlEntry.proRegTxHash, + })), + ); + + return pool; + }; + + await smlDAPIAddressProvider.getLiveAddress(); + + const pool = expectGatewayPool(); + for (let i = 0; i < 20; i++) { + const selected = await listDAPIAddressProvider.getLiveAddress(); + expect(selected.getHost()).to.equal('127.0.0.1'); + expect(selected.isSelfSignedCertificateAllowed()).to.be.true(); + } + + // A refresh resets each entry to its registered endpoint before + // rewriting it again; it must keep the same objects, so their ban state + // survives, and leave none of them on the docker-internal endpoint. + await smlDAPIAddressProvider.getLiveAddress(); + await smlDAPIAddressProvider.getLiveAddress(); + + const refreshedPool = expectGatewayPool(); + refreshedPool.forEach((address, index) => expect(address).to.equal(pool[index])); + }); + + it('should take the network from the list provider when no options are passed', async () => { + // The original three-argument constructor. + const listDAPIAddressProvider = new ListDAPIAddressProvider([], options); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + listDAPIAddressProvider, + [], + ); + + await smlDAPIAddressProvider.getLiveAddress(); + + expect(listDAPIAddressProvider.getAllAddresses().map((address) => address.toJSON())) + .to.deep.equal(validMasternodeList.map((smlEntry, index) => ({ + host: '127.0.0.1', + port: 2443 + index * 100, + protocol: 'https', + allowSelfSignedCertificate: true, + proRegTxHash: smlEntry.proRegTxHash, + }))); + }); + + it('should keep a white-listed masternode on its own gateway port', async () => { + // White-list only the second masternode; dropping the first must not + // move it onto the first one's gateway port. The fixture masternodes + // share one IP, so give each its own to make the white list selective. + const masternodes = mnListDiffFixture.map((entry, index) => ( + new SimplifiedMNListEntry({ ...entry, service: `172.16.0.${11 + index}:20001` }) + )); + smlMock.getValidMasternodesList.returns(masternodes); + + const listDAPIAddressProvider = new ListDAPIAddressProvider([], options); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + listDAPIAddressProvider, + [new DAPIAddress(`172.16.0.12:${masternodes[1].platformHTTPPort}`)], + options, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getProRegTxHash()).to.equal(masternodes[1].proRegTxHash); + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.equal(2543); + + // A refresh resets the entry to its registered endpoint, so the white + // list still matches it and it keeps its port. + await smlDAPIAddressProvider.getLiveAddress(); + + expect(listDAPIAddressProvider.getAllAddresses().map((address) => address.toJSON())) + .to.deep.equal([{ + host: '127.0.0.1', + port: 2543, + protocol: 'https', + allowSelfSignedCertificate: true, + proRegTxHash: masternodes[1].proRegTxHash, + }]); + }); + + it('should rewrite a loopback masternode-list address to the self-signed local gateway', async () => { + // A loopback host still needs the gateway's port and self-signed TLS. + smlMock.getValidMasternodesList.returns([ + new SimplifiedMNListEntry({ ...mnListDiffFixture[2], service: '127.0.0.1:20001' }), + ]); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + new ListDAPIAddressProvider([], options), + [], + options, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.equal(2443); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + + it('should not rewrite a masternode-list address outside regtest', async () => { + // Mainnet: dashcore-lib's regtest is a global flag on its testnet object, + // which the regtest cases above have switched on. + const mainnetOptions = { network: 'mainnet' }; + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + new ListDAPIAddressProvider([], mainnetOptions), + [], + mainnetOptions, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getHost()).to.equal(validMasternodeList[0].getIp()); + expect(liveAddress.getPort()).to.equal(validMasternodeList[0].platformHTTPPort); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.false(); + }); + }); + describe('#hasLiveAddresses', () => { it('should return ListAddressProvider#hasLiveAddresses result', async () => { const result = await smlDAPIAddressProvider.hasLiveAddresses(); diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js index 52710eec1bb..99fc679eb43 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js @@ -1,6 +1,7 @@ const createDAPIAddressProviderFromOptions = require( '../../../lib/dapiAddressProvider/createDAPIAddressProviderFromOptions', ); +const DAPIAddress = require('../../../lib/dapiAddressProvider/DAPIAddress'); const ListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/ListDAPIAddressProvider'); const SimplifiedMasternodeListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider'); @@ -81,6 +82,48 @@ describe('createDAPIAddressProviderFromOptions', () => { expect(result).to.be.an.instanceOf(ListDAPIAddressProvider); }); + it('should not rewrite a caller-supplied non-default regtest address', async () => { + options.dapiAddresses = ['127.0.0.2:45003:self-signed']; + + const provider = createDAPIAddressProviderFromOptions(options); + + const liveAddress = await provider.getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.2'); + expect(liveAddress.getPort()).to.equal(45003); + }); + + // An explicit list is never rewritten, even when its entries name the + // masternode they belong to. + it('should not rewrite an explicit raw address that carries a proRegTxHash', async () => { + options.dapiAddresses = [{ + host: '127.0.0.2', + port: 45003, + allowSelfSignedCertificate: true, + proRegTxHash: 'c'.repeat(64), + }]; + + const liveAddress = await createDAPIAddressProviderFromOptions(options).getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.2'); + expect(liveAddress.getPort()).to.equal(45003); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + + it('should not rewrite an explicit DAPIAddress that carries a proRegTxHash', async () => { + options.dapiAddresses = [new DAPIAddress({ + host: '10.0.0.5', + port: 45003, + proRegTxHash: 'd'.repeat(64), + })]; + + const liveAddress = await createDAPIAddressProviderFromOptions(options).getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('10.0.0.5'); + expect(liveAddress.getPort()).to.equal(45003); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.false(); + }); + it('should throw DAPIClientError if `seeds` option is passed too', async () => { options.seeds = ['127.0.0.1']; @@ -144,6 +187,16 @@ describe('createDAPIAddressProviderFromOptions', () => { expect(result).to.be.an.instanceOf(SimplifiedMasternodeListDAPIAddressProvider); }); + it('should connect the `local` preset to the self-signed local gateway', async () => { + const liveAddress = await createDAPIAddressProviderFromOptions({ network: 'local' }) + .getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.equal(2443); + expect(liveAddress.getProtocol()).to.equal('https'); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + it('should throw DAPIClientError if there is no config for a specified network', async () => { options.network = 'unknown';