Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"semver": "^7.3.5",
"shx": "^0.3.4",
"symbol-sdk": "^2.0.6",
"symbol-statistics-service-typescript-fetch-client": "^1.1.5",
"tslib": "^2.3.1",
"utf8": "^3.0.0",
"winston": "^3.5.1"
Expand Down
2 changes: 1 addition & 1 deletion presets/mainnet/network.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ treasuryReissuanceEpochIneligibleVoterAddresses:
votingUnfinalizedBlocksDuration: 0m
timeSynchronizationMinImportance: 10000000000
explorerUrl: https://symbol.fyi
statisticsServiceUrl: https://symbol.services
nodeWatchUrl: https://nodewatch.symbol.tools
knownRestGateways: []
knownPeers: []
restUncirculatingAccountPublicKeys: >-
Expand Down
6 changes: 2 additions & 4 deletions presets/shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,8 @@ restSSLKeyFileName: 'restSSL.key'
restSSLCertificateFileName: 'restSSL.crt'
restNodeMetadata:
_info: "Node metadata"
statisticsServicePeerFilter: ''
statisticsServicePeerLimit: 50
statisticsServiceRestFilter: suggested
statisticsServiceRestLimit: 10
nodeWatchPeerLimit: 50
nodeWatchRestLimit: 10

treasuryReissuanceTransactionSignatures_has_items: false
corruptAggregateTransactionHashes_has_items: false
Expand Down
2 changes: 1 addition & 1 deletion presets/testnet/network.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ votingUnfinalizedBlocksDuration: 0m
timeSynchronizationMinImportance: 10000000000
faucetUrl: https://testnet.symbol.tools
explorerUrl: https://testnet.symbol.fyi
statisticsServiceUrl: https://testnet.symbol.services
nodeWatchUrl: https://nodewatch.symbol.tools/testnet
nemesis:
mosaics:
-
Expand Down
8 changes: 3 additions & 5 deletions src/model/ConfigPreset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,9 @@ export interface CommonConfigPreset extends NodeConfigPreset, GatewayConfigPrese
useExperimentalNativeVotingKeyGeneration?: boolean;
lastKnownNetworkEpoch: number;
autoUpdateVotingKeys: boolean;
statisticsServiceUrl?: string;
statisticsServicePeerLimit: number;
statisticsServicePeerFilter?: string;
statisticsServiceRestLimit: number;
statisticsServiceRestFilter?: string;
nodeWatchUrl?: string;
nodeWatchPeerLimit: number;
nodeWatchRestLimit: number;

// Nested Objects
inflation?: Record<string, number>;
Expand Down
63 changes: 63 additions & 0 deletions src/service/NodeWatchService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fetch from 'cross-fetch';

export interface NodeWatchNodeInfo {
endpoint: string;
host: string;
peerPort: number;
isHealthy: boolean | null;
publicKey: string;
friendlyName: string;
roles: number;
}

export class NodeWatchService {
private static readonly PEER_PORT = 7900;

constructor(private readonly baseUrl: string) {}

public static mapResponse(response: any): NodeWatchNodeInfo {
return {
endpoint: response.endpoint,
host: NodeWatchService.hostPortFromEndpoint(response.endpoint),
peerPort: NodeWatchService.PEER_PORT,
isHealthy: response.isHealthy,
publicKey: response.mainPublicKey,
friendlyName: response.name,
roles: response.roles,
};
}

private static hostPortFromEndpoint(endpoint: string | null | undefined): string {
if (!endpoint) {
return '';
}
try {
return new URL(endpoint).hostname;
} catch {
return '';
}
}

public async fetchNodesByType(nodeType: 'api' | 'peer', limit: number): Promise<NodeWatchNodeInfo[]> {
const params = new URLSearchParams({
only_ssl: nodeType === 'api' ? 'true' : 'false',
limit: `${limit}`,
order: 'random',
});
const url = `${this.baseUrl}/api/symbol/nodes/peer?${params.toString()}`;
const body = await this.requestJson(url);
if (!Array.isArray(body)) {
throw new Error('Node watch response was not a JSON array');
}
return body.map((node: any) => NodeWatchService.mapResponse(node));
}

private async requestJson(url: string): Promise<any> {
const response = await fetch(url, {});
if (!response.ok) {
const reason = response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
throw new Error(`Node watch request failed: HTTP ${reason}`);
}
return await response.json();
}
}
59 changes: 24 additions & 35 deletions src/service/RemoteNodeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fetch from 'cross-fetch';
import { lookup } from 'dns';
import * as _ from 'lodash';
import { firstValueFrom } from 'rxjs';
import { ChainInfo, RepositoryFactory, RepositoryFactoryHttp, RoleType } from 'symbol-sdk';
import { Configuration, NodeApi, NodeListFilter, RequestContext } from 'symbol-statistics-service-typescript-fetch-client';
import { Logger } from '../logger';
import { ConfigPreset, PeerInfo } from '../model';
import { KnownError } from './KnownError';
import { NodeWatchNodeInfo, NodeWatchService } from './NodeWatchService';
import { Utils } from './Utils';

export interface RepositoryInfo {
Expand Down Expand Up @@ -127,17 +126,20 @@ export class RemoteNodeService {
}
const presetData = this.presetData;
const urls = [...(presetData.knownRestGateways || [])];
const statisticsServiceUrl = presetData.statisticsServiceUrl;
if (statisticsServiceUrl && !this.offline) {
const client = this.createNodeApiRestClient(statisticsServiceUrl);
const nodeWatchUrl = presetData.nodeWatchUrl;
if (nodeWatchUrl && !this.offline) {
try {
const filter = presetData.statisticsServiceRestFilter as NodeListFilter;
const limit = presetData.statisticsServiceRestLimit;
const nodes = await client.getNodes(filter ? filter : undefined, limit);
urls.push(...nodes.map((n) => n.apiStatus?.restGatewayUrl).filter((url): url is string => !!url));
const limit = presetData.nodeWatchRestLimit;
const nodes = await this.getNodeWatchNodes(nodeWatchUrl, 'api', limit);
urls.push(
...nodes
.filter((n) => n.isHealthy === true)
.map((n) => n.endpoint)
.filter((url): url is string => !!url),
);
} catch (e) {
this.logger.warn(
`There has been an error connecting to statistics ${statisticsServiceUrl}. Rest urls cannot be resolved! Error ${Utils.getMessage(
`There has been an error connecting to node watch ${nodeWatchUrl}. Rest urls cannot be resolved! Error ${Utils.getMessage(
e,
)}`,
);
Expand Down Expand Up @@ -170,24 +172,23 @@ export class RemoteNodeService {

public async getPeerInfos(): Promise<PeerInfo[]> {
const presetData = this.presetData;
const statisticsServiceUrl = presetData.statisticsServiceUrl;
const nodeWatchUrl = presetData.nodeWatchUrl;
const knownPeers = [...(presetData.knownPeers || [])];
if (statisticsServiceUrl && !this.offline) {
const client = this.createNodeApiRestClient(statisticsServiceUrl);
if (nodeWatchUrl && !this.offline) {
try {
const filter = presetData.statisticsServicePeerFilter as NodeListFilter;
const limit = presetData.statisticsServicePeerLimit;
const nodes = await client.getNodes(filter ? filter : undefined, limit);
const limit = presetData.nodeWatchPeerLimit;
const nodes = await this.getNodeWatchNodes(nodeWatchUrl, 'peer', limit);
const peerInfos = nodes
.filter((n) => n.isHealthy !== false)
.map((n): PeerInfo | undefined => {
if (!n.peerStatus?.isAvailable || !n.publicKey || !n.port || !n.friendlyName || !n.roles) {
if (!n.publicKey || !n.friendlyName || !n.roles || !n.host) {
return undefined;
}
return {
publicKey: n.publicKey,
endpoint: {
host: n.host || '',
port: n.port,
host: n.host,
port: n.peerPort,
},
metadata: {
name: n.friendlyName,
Expand All @@ -199,7 +200,7 @@ export class RemoteNodeService {
knownPeers.push(...peerInfos);
} catch (error) {
this.logger.warn(
`There has been an error connecting to statistics ${statisticsServiceUrl}. Peers cannot be resolved! Error ${Utils.getMessage(
`There has been an error connecting to node watch ${nodeWatchUrl}. Peers cannot be resolved! Error ${Utils.getMessage(
error,
)}`,
);
Expand All @@ -208,21 +209,9 @@ export class RemoteNodeService {
return knownPeers;
}

public createNodeApiRestClient(statisticsServiceUrl: string): NodeApi {
return new NodeApi(
new Configuration({
fetchApi: fetch as any,
basePath: statisticsServiceUrl,
middleware: [
{
pre: (context: RequestContext): Promise<void> => {
this.logger.info(`Getting nodes information from ${context.url}`);
return Promise.resolve();
},
},
],
}),
);
public async getNodeWatchNodes(nodeWatchUrl: string, nodeType: 'api' | 'peer', limit: number): Promise<NodeWatchNodeInfo[]> {
this.logger.info(`Getting nodes information from ${nodeWatchUrl}`);
return new NodeWatchService(nodeWatchUrl).fetchNodesByType(nodeType, limit);
}

public async resolveRestUrlsForServices(): Promise<{ restNodes: string[]; defaultNode: string }> {
Expand Down
1 change: 1 addition & 0 deletions src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './LinkService';
export * from './MigrationService';
export * from './ModifyMultisigService';
export * from './NemgenService';
export * from './NodeWatchService';
export * from './OSUtils';
export * from './PortService';
export * from './RemoteNodeService';
Expand Down
114 changes: 114 additions & 0 deletions test/service/NodeWatchService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect } from 'chai';
import 'mocha';
import { afterEach, describe, it } from 'mocha';
import { NodeWatchService } from '../../src/service/NodeWatchService';
import nock = require('nock');

const baseUrl = 'https://nodewatch.test';

describe('NodeWatchService', () => {
afterEach(() => {
nock.cleanAll();
});

describe('mapResponse', () => {
it('maps flat NodeWatch JSON row', () => {
const mapped = NodeWatchService.mapResponse({
endpoint: 'https://example.com:3001',
name: 'Node1',
mainPublicKey: 'MAINPUBLICKEYHEX0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF',
nodePublicKey: 'NODEPUBLICKEYHEX0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF',
isSslEnabled: true,
isHealthy: true,
restVersion: '1.0.0',
roles: 3,
});

expect(mapped).to.deep.equal({
endpoint: 'https://example.com:3001',
host: 'example.com',
peerPort: 7900,
isHealthy: true,
publicKey: 'MAINPUBLICKEYHEX0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF',
friendlyName: 'Node1',
roles: 3,
});
});

it('propagates isHealthy null from API', () => {
const mapped = NodeWatchService.mapResponse({
endpoint: '',
name: 'peer-only',
mainPublicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
isHealthy: null,
roles: 1,
});
expect(mapped.isHealthy).eq(null);
expect(mapped.host).eq('');
});

it('returns empty host when endpoint is not a valid URL', () => {
const mapped = NodeWatchService.mapResponse({
endpoint: 'not-a-valid-url',
name: 'x',
mainPublicKey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
isHealthy: false,
roles: 1,
});
expect(mapped.host).eq('');
});
});

describe('fetchNodesByType', () => {
it('calls /nodes/peer with only_ssl=true for nodeType api and maps array body', async () => {
const row = {
endpoint: 'https://dual.example:3001',
name: 'dual',
mainPublicKey: 'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
isHealthy: true,
roles: 3,
};
nock(baseUrl).get('/api/symbol/nodes/peer').query({ only_ssl: 'true', limit: '5', order: 'random' }).reply(200, [row]);

const service = new NodeWatchService(baseUrl);
const nodes = await service.fetchNodesByType('api', 5);

expect(nodes).to.have.lengthOf(1);
expect(nodes[0].friendlyName).eq('dual');
expect(nodes[0].host).eq('dual.example');
expect(nock.isDone()).eq(true);
});

it('calls /nodes/peer with only_ssl=false for nodeType peer', async () => {
nock(baseUrl).get('/api/symbol/nodes/peer').query({ only_ssl: 'false', limit: '1', order: 'random' }).reply(200, []);

const service = new NodeWatchService(baseUrl);
await service.fetchNodesByType('peer', 1);
expect(nock.isDone()).eq(true);
});

it('throws when response body is not a JSON array', async () => {
nock(baseUrl).get('/api/symbol/nodes/peer').query(true).reply(200, { ok: false });

const service = new NodeWatchService(baseUrl);
try {
await service.fetchNodesByType('api', 1);
expect.fail('expected error');
} catch (e: unknown) {
expect((e as Error).message).eq('Node watch response was not a JSON array');
}
});

it('throws when HTTP status is not 2xx', async () => {
nock(baseUrl).get('/api/symbol/nodes/peer').query(true).reply(503);

const service = new NodeWatchService(baseUrl);
try {
await service.fetchNodesByType('peer', 2);
expect.fail('expected error');
} catch (e: unknown) {
expect((e as Error).message).eq('Node watch request failed: HTTP 503 Service Unavailable');
}
});
});
});
Loading
Loading