From c92baad736d35232a5bd76566d83eec2ff7a5ac7 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 13 Sep 2021 17:07:53 +1000 Subject: [PATCH] Fix path building (#1903) * Keep line breaks when message has some new lines Relates #1758 and #1898 and #1901 * fix link preview cropped when received * make sure we fetch from seed if we end up with not enough snodes * try to download recent previews if we just trusted a user * throw if we need to rebuild path while fetching snode list from snode * fixup no refecthing of snode list while we are fetching them already * added test for fetch from db or seed * fetch snode list from snode every hour also make sure the path building does not try to get more snodes. It just throws * do not drop a path if an opengroup room is down and we get errors back * do not throw an error if the snode in error in not in any path * fix tests * bump to v1.7.11 --- libtextsecure/errors.js | 13 - libtextsecure/index.d.ts | 1 - package.json | 4 +- stylesheets/_modules.scss | 15 +- stylesheets/_theme_dark.scss | 9 - ts/components/conversation/Image.tsx | 23 +- .../conversation/StagedAttachmentList.tsx | 1 + .../conversation/StagedLinkPreview.tsx | 9 +- .../message/ClickToTrustSender.tsx | 28 +- .../conversation/message/MessageContent.tsx | 8 +- ts/components/session/ActionsPanel.tsx | 20 +- ts/opengroup/opengroupV2/JoinOpenGroupV2.ts | 6 +- .../opengroupV2/OpenGroupManagerV2.ts | 2 +- .../opengroupV2/OpenGroupServerPoller.ts | 2 +- ts/session/onions/onionPath.ts | 286 +++++++------ ts/session/onions/onionSend.ts | 61 +-- ts/session/seed_node_api/SeedNodeAPI.ts | 306 ++++++++++++++ ts/session/seed_node_api/index.ts | 3 + ts/session/snode_api/SNodeAPI.ts | 252 +++--------- ts/session/snode_api/lokiRpc.ts | 46 ++- ts/session/snode_api/onions.ts | 219 +++++----- ts/session/snode_api/snodePool.ts | 379 +++++++----------- ts/session/snode_api/swarmPolling.ts | 2 +- ts/session/utils/Promise.ts | 2 +- ts/test/session/unit/onion/GuardNodes_test.ts | 196 +++++++++ .../session/unit/onion/OnionErrors_test.ts | 114 +++--- ts/test/session/unit/onion/OnionPaths_test.ts | 72 ++-- .../session/unit/onion/SeedNodeAPI_test.ts | 93 +++++ .../unit/onion/SnodePoolUpdate_test.ts | 101 +++++ 29 files changed, 1375 insertions(+), 898 deletions(-) create mode 100644 ts/session/seed_node_api/SeedNodeAPI.ts create mode 100644 ts/session/seed_node_api/index.ts create mode 100644 ts/test/session/unit/onion/GuardNodes_test.ts create mode 100644 ts/test/session/unit/onion/SeedNodeAPI_test.ts create mode 100644 ts/test/session/unit/onion/SnodePoolUpdate_test.ts diff --git a/libtextsecure/errors.js b/libtextsecure/errors.js index 2b5df2a9cd..80e980b4c1 100644 --- a/libtextsecure/errors.js +++ b/libtextsecure/errors.js @@ -75,18 +75,6 @@ appendStack(this, error); } - function SeedNodeError(message) { - this.name = 'SeedNodeError'; - this.message = message; - Error.call(this, message); - - // Maintains proper stack trace, where our error was thrown (only available on V8) - // via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error - if (Error.captureStackTrace) { - Error.captureStackTrace(this); - } - } - function HTTPError(message, response) { this.name = 'HTTPError'; this.message = `${response.status} Error: ${message}`; @@ -114,7 +102,6 @@ window.textsecure.SendMessageNetworkError = SendMessageNetworkError; window.textsecure.ReplayableError = ReplayableError; window.textsecure.EmptySwarmError = EmptySwarmError; - window.textsecure.SeedNodeError = SeedNodeError; window.textsecure.HTTPError = HTTPError; window.textsecure.NotFoundError = NotFoundError; window.textsecure.TimestampError = TimestampError; diff --git a/libtextsecure/index.d.ts b/libtextsecure/index.d.ts index 7fada89ae0..2d0e1370c4 100644 --- a/libtextsecure/index.d.ts +++ b/libtextsecure/index.d.ts @@ -7,7 +7,6 @@ export interface LibTextsecure { SendMessageNetworkError: any; ReplayableError: any; EmptySwarmError: any; - SeedNodeError: any; HTTPError: any; NotFoundError: any; TimestampError: any; diff --git a/package.json b/package.json index 3c1f6dd5bc..97fd86d7b3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "session-desktop", "productName": "Session", "description": "Private messaging from your desktop", - "version": "1.7.0", + "version": "1.7.1", "license": "GPL-3.0", "author": { "name": "Loki Project", @@ -35,7 +35,7 @@ "build-protobuf": "yarn build-module-protobuf", "clean-protobuf": "yarn clean-module-protobuf", "test": "yarn test-node", - "test-node": "mocha --recursive --exit --timeout 10000 test/app test/modules \"./ts/test/**/*_test.js\" ", + "test-node": "mocha --recursive --exit --timeout 10000 \"./ts/test/**/*_test.js\" ", "eslint-full": "eslint .", "lint-full": "yarn format-full && yarn lint-files-full", "lint-files-full": "yarn eslint-full && yarn tslint", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index de546aa85d..b61df3693d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -174,18 +174,12 @@ .module-message__link-preview { cursor: pointer; - margin-inline-start: -12px; - margin-inline-end: -12px; - margin-top: -10px; - margin-bottom: 5px; border-top-left-radius: $session_message-container-border-radius; border-top-right-radius: $session_message-container-border-radius; } .module-message__link-preview__content { padding: 8px; - border-top-left-radius: $session_message-container-border-radius; - border-top-right-radius: $session_message-container-border-radius; background-color: $color-white; display: flex; flex-direction: row; @@ -1027,8 +1021,12 @@ } .module-image__image { - object-fit: cover; + object-fit: contain; cursor: pointer; + + &-cover { + object-fit: cover; + } } .module-image__bottom-overlay { @@ -1489,9 +1487,10 @@ .module-staged-link-preview--is-loading { align-items: center; + justify-content: center; } .module-staged-link-preview__loading { - color: var(--color-sent-message-text); + color: var(--color-text); font-size: 14px; text-align: center; diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index cb9341bb91..919123a12c 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -424,15 +424,6 @@ // Module: Staged Link Preview - .module-staged-link-preview__loading { - color: $color-gray-25; - } - .module-staged-link-preview__title { - color: $color-gray-05; - } - .module-staged-link-preview__location { - color: $color-gray-25; - } .module-staged-link-preview__close-button { @include color-svg('../images/x-16.svg', $color-gray-25); } diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index bed54e179a..d052213cad 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -21,6 +21,7 @@ type Props = { darkOverlay?: boolean; playIconOverlay?: boolean; softCorners?: boolean; + forceSquare?: boolean; onClick?: (attachment: AttachmentTypeWithPath | AttachmentType) => void; onClickClose?: (attachment: AttachmentTypeWithPath | AttachmentType) => void; @@ -42,6 +43,7 @@ export const Image = (props: Props) => { overlayText, playIconOverlay, softCorners, + forceSquare, url, width, } = props; @@ -84,13 +86,17 @@ export const Image = (props: Props) => { canClick ? 'module-image__with-click-handler' : null, softCorners ? 'module-image--soft-corners' : null )} + style={{ + maxHeight: `${height}px`, + maxWidth: `${width}px`, + }} > {pending || loading ? (
{ ) : ( {alt} diff --git a/ts/components/conversation/StagedAttachmentList.tsx b/ts/components/conversation/StagedAttachmentList.tsx index 6301a95ee0..ebd2c7aa65 100644 --- a/ts/components/conversation/StagedAttachmentList.tsx +++ b/ts/components/conversation/StagedAttachmentList.tsx @@ -79,6 +79,7 @@ export const StagedAttachmentList = (props: Props) => { playIconOverlay={isVideoAttachment(attachment)} height={IMAGE_HEIGHT} width={IMAGE_WIDTH} + forceSquare={true} url={getUrl(attachment)} closeButton={true} onClick={clickCallback} diff --git a/ts/components/conversation/StagedLinkPreview.tsx b/ts/components/conversation/StagedLinkPreview.tsx index 823fb84dbb..b9b3775349 100644 --- a/ts/components/conversation/StagedLinkPreview.tsx +++ b/ts/components/conversation/StagedLinkPreview.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { Image } from './Image'; import { AttachmentType, isImageAttachment } from '../../types/Attachment'; +import { SessionSpinner } from '../session/SessionSpinner'; type Props = { isLoaded: boolean; @@ -24,16 +25,16 @@ export const StagedLinkPreview = (props: Props) => { return <>; } + const isLoading = !isLoaded; + return (
- {!isLoaded ? ( -
{window.i18n('loading')}
- ) : null} + {isLoading ? : null} {isLoaded && image && isImage ? (
{ await Promise.all( messagesInConvo.map(async message => { const msgAttachments = message.get('attachments'); + const messagePreviews = message.get('preview'); if (message.get('direction') !== 'incoming') { return; } - if (!msgAttachments || msgAttachments.length === 0) { + if ( + (!msgAttachments || msgAttachments.length === 0) && + (!messagePreviews || messagePreviews.length === 0) + ) { return; } @@ -72,6 +76,28 @@ export const ClickToTrustSender = (props: { messageId: string }) => { }) ); + const preview = await Promise.all( + (messagePreviews || []).map(async (item: any, index: any) => { + if (!item.image) { + return item; + } + + const image = message.isTrustedForAttachmentDownload() + ? await AttachmentDownloads.addJob(item.image, { + messageId: message.id, + type: 'preview', + index, + isOpenGroupV2: false, + openGroupV2Details: undefined, + }) + : null; + + return { ...item, image }; + }) + ); + + message.set({ preview }); + message.set({ attachments: downloadedAttachments }); await message.commit(); }) diff --git a/ts/components/conversation/message/MessageContent.tsx b/ts/components/conversation/message/MessageContent.tsx index 051c8cfa9c..5ad5c3471d 100644 --- a/ts/components/conversation/message/MessageContent.tsx +++ b/ts/components/conversation/message/MessageContent.tsx @@ -177,10 +177,12 @@ export const MessageContent = (props: Props) => { handleImageError={handleImageError} /> {hasContentAfterAttachmentAndQuote ? ( - + <> - - + + + + ) : null} diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index cd2930b2cb..b2efb92c5a 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -14,9 +14,12 @@ import { removeConversation, removeOneOpenGroupV1Message, } from '../../data/data'; -import { OnionPaths } from '../../session/onions'; import { getMessageQueue } from '../../session/sending'; import { useDispatch, useSelector } from 'react-redux'; +// tslint:disable: no-submodule-imports +import useInterval from 'react-use/lib/useInterval'; +import useTimeoutFn from 'react-use/lib/useTimeoutFn'; + import { getOurNumber } from '../../state/selectors/user'; import { getOurPrimaryConversation, @@ -24,7 +27,6 @@ import { } from '../../state/selectors/conversations'; import { applyTheme } from '../../state/ducks/theme'; import { getFocusedSection } from '../../state/selectors/section'; -import { useInterval } from '../../hooks/useInterval'; import { clearSearch } from '../../state/ducks/search'; import { SectionType, showLeftPaneSection } from '../../state/ducks/section'; @@ -204,11 +206,6 @@ const triggerAvatarReUploadIfNeeded = async () => { * This function is called only once: on app startup with a logged in user */ const doAppStartUp = () => { - if (window.lokiFeatureFlags.useOnionRequests || window.lokiFeatureFlags.useFileOnionRequests) { - // Initialize paths for onion requests - void OnionPaths.buildNewOnionPathsOneAtATime(); - } - // init the messageQueue. In the constructor, we add all not send messages // this call does nothing except calling the constructor, which will continue sending message in the pipeline void getMessageQueue().processAllPending(); @@ -271,8 +268,15 @@ export const ActionsPanel = () => { }, DURATION.DAYS * 2); useInterval(() => { + // trigger an updates from the snodes every hour + void forceRefreshRandomSnodePool(); - }, DURATION.DAYS * 1); + }, DURATION.HOURS * 1); + + useTimeoutFn(() => { + // trigger an updates from the snodes after 5 minutes, once + void forceRefreshRandomSnodePool(); + }, DURATION.MINUTES * 5); useInterval(() => { // this won't be run every days, but if the app stays open for more than 10 days diff --git a/ts/opengroup/opengroupV2/JoinOpenGroupV2.ts b/ts/opengroup/opengroupV2/JoinOpenGroupV2.ts index 63fa51a62a..4c3ecb7664 100644 --- a/ts/opengroup/opengroupV2/JoinOpenGroupV2.ts +++ b/ts/opengroup/opengroupV2/JoinOpenGroupV2.ts @@ -94,8 +94,8 @@ async function joinOpenGroupV2(room: OpenGroupV2Room, fromConfigMessage: boolean await forceSyncConfigurationNowIfNeeded(); } } catch (e) { - window?.log?.error('Could not join open group v2', e); - throw new Error(e); + window?.log?.error('Could not join open group v2', e.message); + throw e; } } @@ -157,7 +157,7 @@ export async function joinOpenGroupV2WithUIEvents( } } } catch (error) { - window?.log?.warn('got error while joining open group:', error); + window?.log?.warn('got error while joining open group:', error.message); if (showToasts) { ToastUtils.pushToastError('connectToServerFail', window.i18n('connectToServerFail')); } diff --git a/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts b/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts index dba353ec1b..5c7fb0738b 100644 --- a/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts +++ b/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts @@ -211,7 +211,7 @@ export class OpenGroupManagerV2 { return conversation; } catch (e) { - window?.log?.warn('Failed to join open group v2', e); + window?.log?.warn('Failed to join open group v2', e.message); await removeV2OpenGroupRoom(conversationId); // throw new Error(window.i18n('connectToServerFail')); return undefined; diff --git a/ts/opengroup/opengroupV2/OpenGroupServerPoller.ts b/ts/opengroup/opengroupV2/OpenGroupServerPoller.ts index c25101f663..2ad36923f2 100644 --- a/ts/opengroup/opengroupV2/OpenGroupServerPoller.ts +++ b/ts/opengroup/opengroupV2/OpenGroupServerPoller.ts @@ -348,7 +348,7 @@ export class OpenGroupServerPoller { // ==> At this point all those results need to trigger conversation updates, so update what we have to update await handleCompactPollResults(this.serverUrl, compactFetchResults); } catch (e) { - window?.log?.warn('Got error while compact fetch:', e); + window?.log?.warn('Got error while compact fetch:', e.message); } finally { this.isPolling = false; } diff --git a/ts/session/onions/onionPath.ts b/ts/session/onions/onionPath.ts index 789ad67014..63ce1b3fd5 100644 --- a/ts/session/onions/onionPath.ts +++ b/ts/session/onions/onionPath.ts @@ -1,4 +1,4 @@ -import { getGuardNodes, Snode, updateGuardNodes } from '../../../ts/data/data'; +import * as Data from '../../../ts/data/data'; import * as SnodePool from '../snode_api/snodePool'; import _ from 'lodash'; import { default as insecureNodeFetch } from 'node-fetch'; @@ -14,8 +14,10 @@ import { updateOnionPaths } from '../../state/ducks/onion'; import { ERROR_CODE_NO_CONNECT } from '../snode_api/SNodeAPI'; import { getStoragePubKey } from '../types/PubKey'; +import { OnionPaths } from './'; + const ONION_REQUEST_HOPS = 3; -export let onionPaths: Array> = []; +export let onionPaths: Array> = []; /** * Used for testing only @@ -59,17 +61,18 @@ const pathFailureThreshold = 3; // This array is meant to store nodes will full info, // so using GuardNode would not be correct (there is // some naming issue here it seems) -export let guardNodes: Array = []; +export let guardNodes: Array = []; export const ed25519Str = (ed25519Key: string) => `(...${ed25519Key.substr(58)})`; -let buildNewOnionPathsWorkerRetry = 0; - export async function buildNewOnionPathsOneAtATime() { // this function may be called concurrently make sure we only have one inflight return allowOnlyOneAtATime('buildNewOnionPaths', async () => { - buildNewOnionPathsWorkerRetry = 0; - await buildNewOnionPathsWorker(); + try { + await buildNewOnionPathsWorker(); + } catch (e) { + window?.log?.warn(`buildNewOnionPathsWorker failed with ${e.message}`); + } }); } @@ -91,10 +94,11 @@ export async function dropSnodeFromPath(snodeEd25519: string) { ); if (pathWithSnodeIndex === -1) { - window?.log?.warn( - `Could not drop ${ed25519Str(snodeEd25519)} from path index: ${pathWithSnodeIndex}` - ); - throw new Error(`Could not drop snode ${ed25519Str(snodeEd25519)} from path: not in any paths`); + window?.log?.warn(`Could not drop ${ed25519Str(snodeEd25519)} as it is not in any paths`); + // this can happen for instance if the snode given is the destination snode. + // like a `retrieve` request returns node not found being the request the snode is made to. + // in this case, nothing bad is happening for the path. We just have to use another snode to do the request + return; } window?.log?.info( `dropping snode ${ed25519Str(snodeEd25519)} from path index: ${pathWithSnodeIndex}` @@ -121,15 +125,24 @@ export async function dropSnodeFromPath(snodeEd25519: string) { onionPaths[pathWithSnodeIndex] = pathtoPatchUp; } -export async function getOnionPath(toExclude?: Snode): Promise> { +export async function getOnionPath({ + toExclude, +}: { + toExclude?: Data.Snode; +}): Promise> { let attemptNumber = 0; + // the buildNewOnionPathsOneAtATime will try to fetch from seed if it needs more snodes while (onionPaths.length < minimumGuardCount) { window?.log?.info( - `Must have at least ${minimumGuardCount} good onion paths, actual: ${onionPaths.length}, attempt #${attemptNumber} fetching more...` + `getOnionPath: Must have at least ${minimumGuardCount} good onion paths, actual: ${onionPaths.length}, attempt #${attemptNumber}` ); - // eslint-disable-next-line no-await-in-loop - await buildNewOnionPathsOneAtATime(); + try { + // eslint-disable-next-line no-await-in-loop + await buildNewOnionPathsOneAtATime(); + } catch (e) { + window?.log?.warn(`buildNewOnionPathsOneAtATime failed with ${e.message}`); + } // should we add a delay? buildNewOnionPathsOneA tATime should act as one // reload goodPaths now @@ -141,7 +154,7 @@ export async function getOnionPath(toExclude?: Snode): Promise> { } } - if (onionPaths.length <= 0) { + if (onionPaths.length === 0) { if (!_.isEmpty(window.inboxStore?.getState().onionPaths.snodePaths)) { window.inboxStore?.dispatch(updateOnionPaths([])); } @@ -156,23 +169,29 @@ export async function getOnionPath(toExclude?: Snode): Promise> { } } - const onionPathsWithoutExcluded = toExclude - ? onionPaths.filter( - path => !_.some(path, node => node.pubkey_ed25519 === toExclude.pubkey_ed25519) - ) - : onionPaths; - - if (!onionPathsWithoutExcluded) { - window?.log?.error('LokiSnodeAPI::getOnionPath - no path in', onionPathsWithoutExcluded); - return []; + if (!toExclude) { + // no need to exclude a node, then just return a random path from the list of path + if (!onionPaths || onionPaths.length === 0) { + throw new Error('No onion paths available'); + } + const randomPathNoExclude = _.sample(onionPaths); + if (!randomPathNoExclude) { + throw new Error('No onion paths available'); + } + return randomPathNoExclude; } + // here we got a snode to exclude from the returned path + const onionPathsWithoutExcluded = onionPaths.filter( + path => !_.some(path, node => node.pubkey_ed25519 === toExclude.pubkey_ed25519) + ); + if (!onionPathsWithoutExcluded || onionPathsWithoutExcluded.length === 0) { + throw new Error('No onion paths available after filtering'); + } const randomPath = _.sample(onionPathsWithoutExcluded); - if (!randomPath) { throw new Error('No onion paths available after filtering'); } - return randomPath; } @@ -185,11 +204,11 @@ export async function incrementBadPathCountOrDrop(snodeEd25519: string) { ); if (pathWithSnodeIndex === -1) { - window?.log?.info('Did not find any path containing this snode'); - // this can only be bad. throw an abortError so we use another path if needed - throw new pRetry.AbortError( - 'incrementBadPathCountOrDrop: Did not find any path containing this snode' - ); + window?.log?.info('incrementBadPathCountOrDrop: Did not find any path containing this snode'); + // this might happen if the snodeEd25519 is the one of the target snode, just increment the target snode count by 1 + await incrementBadSnodeCountOrDrop({ snodeEd25519 }); + + return; } const guardNodeEd25519 = onionPaths[pathWithSnodeIndex][0].pubkey_ed25519; @@ -210,7 +229,7 @@ export async function incrementBadPathCountOrDrop(snodeEd25519: string) { // a guard node is dropped when the path is dropped completely (in dropPathStartingWithGuardNode) for (let index = 1; index < pathWithIssues.length; index++) { const snode = pathWithIssues[index]; - await incrementBadSnodeCountOrDrop({ snodeEd25519: snode.pubkey_ed25519, guardNodeEd25519 }); + await incrementBadSnodeCountOrDrop({ snodeEd25519: snode.pubkey_ed25519 }); } if (newPathFailureCount >= pathFailureThreshold) { @@ -226,7 +245,8 @@ export async function incrementBadPathCountOrDrop(snodeEd25519: string) { * @param ed25519Key the guard node ed25519 pubkey */ async function dropPathStartingWithGuardNode(guardNodeEd25519: string) { - // we are dropping it. Reset the counter in case this same guard gets choosen later + await SnodePool.dropSnodeFromSnodePool(guardNodeEd25519); + const failingPathIndex = onionPaths.findIndex(p => p[0].pubkey_ed25519 === guardNodeEd25519); if (failingPathIndex === -1) { window?.log?.warn('No such path starts with this guard node '); @@ -241,22 +261,22 @@ async function dropPathStartingWithGuardNode(guardNodeEd25519: string) { // make sure to drop the guard node even if the path starting with this guard node is not found guardNodes = guardNodes.filter(g => g.pubkey_ed25519 !== guardNodeEd25519); - pathFailureCount[guardNodeEd25519] = 0; - - await SnodePool.dropSnodeFromSnodePool(guardNodeEd25519); - // write the updates guard nodes to the db. - // the next call to getOnionPath will trigger a rebuild of the path await internalUpdateGuardNodes(guardNodes); + // we are dropping it. Reset the counter in case this same guard gets choosen later + pathFailureCount[guardNodeEd25519] = 0; + + // trigger path rebuilding for the dropped path. This will throw if anything happens + await buildNewOnionPathsOneAtATime(); } -async function internalUpdateGuardNodes(updatedGuardNodes: Array) { +async function internalUpdateGuardNodes(updatedGuardNodes: Array) { const edKeys = updatedGuardNodes.map(n => n.pubkey_ed25519); - await updateGuardNodes(edKeys); + await Data.updateGuardNodes(edKeys); } -async function testGuardNode(snode: Snode) { +export async function TEST_testGuardNode(snode: Data.Snode) { window?.log?.info(`Testing a candidate guard node ${ed25519Str(snode.pubkey_ed25519)}`); // Send a post request and make sure it is OK @@ -316,13 +336,16 @@ async function testGuardNode(snode: Snode) { } /** - * Only exported for testing purpose. DO NOT use this directly + * Only exported for testing purpose. + * If the random snode p */ -export async function selectGuardNodes(): Promise> { - // `getRandomSnodePool` is expected to refresh itself on low nodes - const nodePool = await SnodePool.getRandomSnodePool(); - window.log.info('selectGuardNodes snodePool:', nodePool.length); - if (nodePool.length < desiredGuardCount) { +export async function selectGuardNodes(): Promise> { + // `getSnodePoolFromDBOrFetchFromSeed` does not refetch stuff. It just throws. + // this is to avoid having circular dependencies of path building, needing new snodes, which needs new paths building... + const nodePool = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); + + window.log.info(`selectGuardNodes snodePool length: ${nodePool.length}`); + if (nodePool.length < SnodePool.minSnodePoolCount) { window?.log?.error( `Could not select guard nodes. Not enough nodes in the pool: ${nodePool.length}` ); @@ -333,7 +356,7 @@ export async function selectGuardNodes(): Promise> { const shuffled = _.shuffle(nodePool); - let selectedGuardNodes: Array = []; + let selectedGuardNodes: Array = []; let attempts = 0; @@ -345,14 +368,10 @@ export async function selectGuardNodes(): Promise> { window?.log?.error('selectedGuardNodes: offline'); throw new Error('selectedGuardNodes: offline'); } - if (shuffled.length < desiredGuardCount) { - window?.log?.error('Not enough nodes in the pool'); - break; - } const candidateNodes = shuffled.splice(0, desiredGuardCount); - if (attempts > 10) { + if (attempts > 5) { // too many retries. something is wrong. window.log.info(`selectGuardNodes stopping after attempts: ${attempts}`); throw new Error(`selectGuardNodes stopping after attempts: ${attempts}`); @@ -361,122 +380,125 @@ export async function selectGuardNodes(): Promise> { // Test all three nodes at once, wait for all to resolve or reject // eslint-disable-next-line no-await-in-loop - const idxOk = (await Promise.allSettled(candidateNodes.map(testGuardNode))).flatMap(p => - p.status === 'fulfilled' ? p.value : null - ); + const idxOk = ( + await Promise.allSettled(candidateNodes.map(OnionPaths.TEST_testGuardNode)) + ).flatMap(p => (p.status === 'fulfilled' ? p.value : null)); const goodNodes = _.zip(idxOk, candidateNodes) .filter(x => x[0]) - .map(x => x[1]) as Array; + .map(x => x[1]) as Array; selectedGuardNodes = _.concat(selectedGuardNodes, goodNodes); attempts++; } - if (selectedGuardNodes.length < desiredGuardCount) { + guardNodes = selectedGuardNodes; + + if (guardNodes.length < desiredGuardCount) { window?.log?.error(`Cound't get enough guard nodes, only have: ${guardNodes.length}`); + throw new Error(`Cound't get enough guard nodes, only have: ${guardNodes.length}`); } - guardNodes = selectedGuardNodes; await internalUpdateGuardNodes(guardNodes); return guardNodes; } -async function buildNewOnionPathsWorker() { - window?.log?.info('LokiSnodeAPI::buildNewOnionPaths - building new onion paths...'); - - let allNodes = await SnodePool.getRandomSnodePool(); - +/** + * Fetches from db if needed the current guard nodes. + * If we do find in the snode pool (cached or got from seed in here) those guard nodes, use them. + * Otherwise select new guard nodes (might refetch from seed if needed). + * + * This function might throw + * + * This function will not try to fetch snodes from snodes. Only from seed. + * This is to avoid circular dependency of building new path needing new snodes, which needs a new path,... + */ +export async function getGuardNodeOrSelectNewOnes() { if (guardNodes.length === 0) { // Not cached, load from DB - const nodes = await getGuardNodes(); + const guardNodesFromDb = await Data.getGuardNodes(); - if (nodes.length === 0) { + if (guardNodesFromDb.length === 0) { window?.log?.warn( - 'LokiSnodeAPI::buildNewOnionPaths - no guard nodes in DB. Will be selecting new guards nodes...' + 'LokiSnodeAPI::getGuardNodeOrSelectNewOnes - no guard nodes in DB. Will be selecting new guards nodes...' ); } else { + const allNodes = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); // We only store the nodes' keys, need to find full entries: - const edKeys = nodes.map(x => x.ed25519PubKey); + const edKeys = guardNodesFromDb.map(x => x.ed25519PubKey); guardNodes = allNodes.filter(x => edKeys.indexOf(x.pubkey_ed25519) !== -1); - if (guardNodes.length < edKeys.length) { window?.log?.warn( - `LokiSnodeAPI::buildNewOnionPaths - could not find some guard nodes: ${guardNodes.length}/${edKeys.length} left` + `LokiSnodeAPI::getGuardNodeOrSelectNewOnes - could not find some guard nodes: ${guardNodes.length}/${edKeys.length} left` ); } } } // If guard nodes is still empty (the old nodes are now invalid), select new ones: if (guardNodes.length < desiredGuardCount) { - try { - guardNodes = await exports.selectGuardNodes(); - } catch (e) { - window.log.warn('selectGuardNodes throw error. Not retrying.', e); - return; - } + // if an error is thrown, the caller must take care of it. + guardNodes = await OnionPaths.selectGuardNodes(); } - // be sure to fetch again as that list might have been refreshed by selectGuardNodes - allNodes = await SnodePool.getRandomSnodePool(); - window?.log?.info( - 'LokiSnodeAPI::buildNewOnionPaths - after refetch, snodePool length:', - allNodes.length - ); - // TODO: select one guard node and 2 other nodes randomly - let otherNodes = _.differenceBy(allNodes, guardNodes, 'pubkey_ed25519'); - if (otherNodes.length <= SnodePool.minSnodePoolCount) { - window?.log?.warn( - 'LokiSnodeAPI::buildNewOnionPaths - Too few nodes to build an onion path! Refreshing pool and retrying' - ); - await SnodePool.refreshRandomPool(); - // this is a recursive call limited to only one call at a time. we use the timeout - // here to make sure we retry this call if we cannot get enough otherNodes - - // how to handle failing to rety - buildNewOnionPathsWorkerRetry = buildNewOnionPathsWorkerRetry + 1; - window?.log?.warn( - 'buildNewOnionPathsWorker failed to get otherNodes. Current retry:', - buildNewOnionPathsWorkerRetry - ); - if (buildNewOnionPathsWorkerRetry >= 3) { - // we failed enough. Something is wrong. Lets get out of that function and get a new fresh call. - window?.log?.warn( - `buildNewOnionPathsWorker failed to get otherNodes even after retries... Exiting after ${buildNewOnionPathsWorkerRetry} retries` - ); +} - return; - } else { - window?.log?.info( - `buildNewOnionPathsWorker failed to get otherNodes. Next attempt: ${buildNewOnionPathsWorkerRetry}` - ); - } - await buildNewOnionPathsWorker(); - return; - } +async function buildNewOnionPathsWorker() { + return pRetry( + async () => { + window?.log?.info('LokiSnodeAPI::buildNewOnionPaths - building new onion paths...'); - otherNodes = _.shuffle(otherNodes); - const guards = _.shuffle(guardNodes); + // get an up to date list of snodes from cache, from db, or from the a seed node. + let allNodes = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); - // Create path for every guard node: - const nodesNeededPerPaths = ONION_REQUEST_HOPS - 1; + if (allNodes.length <= SnodePool.minSnodePoolCount) { + throw new Error(`Cannot rebuild path as we do not have enough snodes: ${allNodes.length}`); + } - // Each path needs nodesNeededPerPaths nodes in addition to the guard node: - const maxPath = Math.floor(Math.min(guards.length, otherNodes.length / nodesNeededPerPaths)); - window?.log?.info( - `Building ${maxPath} onion paths based on guard nodes length: ${guards.length}, other nodes length ${otherNodes.length} ` - ); + // make sure we have enough guard nodes to build the paths + // this function will throw if for some reason we cannot do it + await OnionPaths.getGuardNodeOrSelectNewOnes(); - // TODO: might want to keep some of the existing paths - onionPaths = []; + // be sure to fetch again as that list might have been refreshed by selectGuardNodes + allNodes = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); + window?.log?.info(`LokiSnodeAPI::buildNewOnionPaths, snodePool length: ${allNodes.length}`); + // get all snodes minus the selected guardNodes + if (allNodes.length <= SnodePool.minSnodePoolCount) { + throw new Error('Too few nodes to build an onion path. Even after fetching from seed.'); + } + const otherNodes = _.shuffle(_.differenceBy(allNodes, guardNodes, 'pubkey_ed25519')); + const guards = _.shuffle(guardNodes); - for (let i = 0; i < maxPath; i += 1) { - const path = [guards[i]]; - for (let j = 0; j < nodesNeededPerPaths; j += 1) { - path.push(otherNodes[i * nodesNeededPerPaths + j]); - } - onionPaths.push(path); - } + // Create path for every guard node: + const nodesNeededPerPaths = ONION_REQUEST_HOPS - 1; + + // Each path needs nodesNeededPerPaths nodes in addition to the guard node: + const maxPath = Math.floor(Math.min(guards.length, otherNodes.length / nodesNeededPerPaths)); + window?.log?.info( + `Building ${maxPath} onion paths based on guard nodes length: ${guards.length}, other nodes length ${otherNodes.length} ` + ); + + // TODO: might want to keep some of the existing paths + onionPaths = []; + + for (let i = 0; i < maxPath; i += 1) { + const path = [guards[i]]; + for (let j = 0; j < nodesNeededPerPaths; j += 1) { + path.push(otherNodes[i * nodesNeededPerPaths + j]); + } + onionPaths.push(path); + } - window?.log?.info(`Built ${onionPaths.length} onion paths`); + window?.log?.info(`Built ${onionPaths.length} onion paths`); + }, + { + retries: 3, // 4 total + factor: 1, + minTimeout: 1000, + onFailedAttempt: e => { + window?.log?.warn( + `buildNewOnionPathsWorker attemp #${e.attemptNumber} failed. ${e.retriesLeft} retries left... Error: ${e.message}` + ); + }, + } + ); } diff --git a/ts/session/onions/onionSend.ts b/ts/session/onions/onionSend.ts index a27381775e..556016a6a4 100644 --- a/ts/session/onions/onionSend.ts +++ b/ts/session/onions/onionSend.ts @@ -72,7 +72,7 @@ const buildSendViaOnionPayload = (url: URL, fetchOptions: OnionFetchOptions): On export const getOnionPathForSending = async () => { let pathNodes: Array = []; try { - pathNodes = await OnionPaths.getOnionPath(); + pathNodes = await OnionPaths.getOnionPath({}); } catch (e) { window?.log?.error(`sendViaOnion - getOnionPath Error ${e.code} ${e.message}`); } @@ -92,39 +92,6 @@ const initOptionsWithDefaults = (options: OnionFetchBasicOptions) => { return _.defaults(options, defaultFetchBasicOptions); }; -const sendViaOnionToNonSnodeRetryable = async ({ - castedDestinationX25519Key, - finalRelayOptions, - payloadObj, - abortSignal, -}: { - castedDestinationX25519Key: string; - finalRelayOptions: FinalRelayOptions; - payloadObj: OnionPayloadObj; - abortSignal?: AbortSignal; -}) => { - const pathNodes = await getOnionPathForSending(); - - if (!pathNodes) { - throw new Error('getOnionPathForSending is emtpy'); - } - - /** - * This call handles ejecting a snode or a path if needed. If that happens, it throws a retryable error and the pRetry - * call above will call us again with the same params but a different path. - * If the error is not recoverable, it throws a pRetry.AbortError. - */ - const result: SnodeResponse = await sendOnionRequestHandlingSnodeEject({ - nodePath: pathNodes, - destX25519Any: castedDestinationX25519Key, - finalDestOptions: payloadObj, - finalRelayOptions, - abortSignal, - }); - - return result; -}; - /** * * This function can be used to make a request via onion to a non snode server. @@ -173,18 +140,28 @@ export const sendViaOnionToNonSnode = async ( try { result = await pRetry( async () => { - return sendViaOnionToNonSnodeRetryable({ - castedDestinationX25519Key, + const pathNodes = await getOnionPathForSending(); + + if (!pathNodes) { + throw new Error('getOnionPathForSending is emtpy'); + } + + /** + * This call handles ejecting a snode or a path if needed. If that happens, it throws a retryable error and the pRetry + * call above will call us again with the same params but a different path. + * If the error is not recoverable, it throws a pRetry.AbortError. + */ + return sendOnionRequestHandlingSnodeEject({ + nodePath: pathNodes, + destX25519Any: castedDestinationX25519Key, + finalDestOptions: payloadObj, finalRelayOptions, - payloadObj, abortSignal, }); }, { - retries: 4, // each path can fail 3 times before being dropped, we have 3 paths at most - factor: 1, - minTimeout: 100, - maxTimeout: 4000, + retries: 2, // retry 3 (2+1) times at most + minTimeout: 500, onFailedAttempt: e => { window?.log?.warn( `sendViaOnionToNonSnodeRetryable attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` @@ -193,7 +170,7 @@ export const sendViaOnionToNonSnode = async ( } ); } catch (e) { - window?.log?.warn('sendViaOnionToNonSnodeRetryable failed ', e); + window?.log?.warn('sendViaOnionToNonSnodeRetryable failed ', e.message); return null; } diff --git a/ts/session/seed_node_api/SeedNodeAPI.ts b/ts/session/seed_node_api/SeedNodeAPI.ts new file mode 100644 index 0000000000..cae1cf369d --- /dev/null +++ b/ts/session/seed_node_api/SeedNodeAPI.ts @@ -0,0 +1,306 @@ +import { Constants } from '..'; +import { default as insecureNodeFetch } from 'node-fetch'; +import https from 'https'; +import _ from 'lodash'; + +import fs from 'fs'; +import path from 'path'; +import tls from 'tls'; +import Electron from 'electron'; +import { sha256 } from '../crypto'; +import * as Data from '../../../ts/data/data'; +import pRetry from 'p-retry'; +import { SeedNodeAPI } from '.'; + +const { remote } = Electron; +// tslint:disable: function-name + +export type SeedNode = { + url: string; +}; + +/** + * Fetch all snodes from seed nodes. + * Exported only for tests. This is not to be used by the app directly + * @param seedNodes the seednodes to use to fetch snodes details + */ +export async function fetchSnodePoolFromSeedNodeWithRetries( + seedNodes: Array +): Promise> { + try { + window?.log?.info(`fetchSnodePoolFromSeedNode with seedNodes.length ${seedNodes.length}`); + + let snodes = await getSnodeListFromSeednode(seedNodes); + // make sure order of the list is random, so we get version in a non-deterministic way + snodes = _.shuffle(snodes); + // commit changes to be live + // we'll update the version (in case they upgrade) every cycle + const fetchSnodePool = snodes.map(snode => ({ + ip: snode.public_ip, + port: snode.storage_port, + pubkey_x25519: snode.pubkey_x25519, + pubkey_ed25519: snode.pubkey_ed25519, + })); + window?.log?.info( + 'SeedNodeAPI::fetchSnodePoolFromSeedNodeWithRetries - Refreshed random snode pool with', + snodes.length, + 'snodes' + ); + return fetchSnodePool; + } catch (e) { + window?.log?.warn( + 'LokiSnodeAPI::fetchSnodePoolFromSeedNodeWithRetries - error', + e.code, + e.message + ); + + throw new Error('Failed to contact seed node'); + } +} + +const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => { + let filePrefix = ''; + let pubkey256 = ''; + let cert256 = ''; + if (!isSsl) { + return undefined; + } + + switch (seedNodeHost) { + case 'storage.seed1.loki.network': + filePrefix = 'storage-seed-1'; + pubkey256 = 'JOsnIcAanVbgECNA8lHtC8f/cqN9m8EP7jKT6XCjeL8='; + cert256 = + '6E:2B:AC:F3:6E:C1:FF:FF:24:F3:CA:92:C6:94:81:B4:82:43:DF:C7:C6:03:98:B8:F5:6B:7D:30:7B:16:C1:CB'; + break; + case 'storage.seed3.loki.network': + filePrefix = 'storage-seed-3'; + pubkey256 = 'mMmZD3lG4Fi7nTC/EWzRVaU3bbCLsH6Ds2FHSTpo0Rk='; + cert256 = + '24:13:4C:0A:03:D8:42:A6:09:DE:35:76:F4:BD:FB:11:60:DB:F9:88:9F:98:46:B7:60:A6:60:0C:4C:CF:60:72'; + + break; + case 'public.loki.foundation': + filePrefix = 'public-loki-foundation'; + pubkey256 = 'W+Zv52qlcm1BbdpJzFwxZrE7kfmEboq7h3Dp/+Q3RPg='; + cert256 = + '40:E4:67:7D:18:6B:4D:08:8D:E9:D5:47:52:25:B8:28:E0:D3:63:99:9B:38:46:7D:92:19:5B:61:B9:AE:0E:EA'; + + break; + + default: + throw new Error(`Unknown seed node: ${seedNodeHost}`); + } + // tslint:disable: non-literal-fs-path + // read the cert each time. We only run this request once for each seed node nevertheless. + const appPath = remote.app.getAppPath(); + const crt = fs.readFileSync(path.join(appPath, `/certificates/${filePrefix}.crt`), 'utf-8'); + const sslOptions = { + // as the seed nodes are using a self signed certificate, we have to provide it here. + ca: crt, + // we have to reject them, otherwise our errors returned in the checkServerIdentity are simply not making the call fail. + // so in production, rejectUnauthorized must be true. + rejectUnauthorized: true, + keepAlive: false, + checkServerIdentity: (host: string, cert: any) => { + // Make sure the certificate is issued to the host we are connected to + const err = tls.checkServerIdentity(host, cert); + if (err) { + return err; + } + + // Pin the public key, similar to HPKP pin-sha25 pinning + if (sha256(cert.pubkey) !== pubkey256) { + const msg = + 'Certificate verification error: ' + + `The public key of '${cert.subject.CN}' ` + + 'does not match our pinned fingerprint'; + return new Error(msg); + } + + // Pin the exact certificate, rather than the pub key + if (cert.fingerprint256 !== cert256) { + const msg = + 'Certificate verification error: ' + + `The certificate of '${cert.subject.CN}' ` + + 'does not match our pinned fingerprint'; + return new Error(msg); + } + return undefined; + }, + }; + + // we're creating a new Agent that will now use the certs we have configured + return new https.Agent(sslOptions); +}; + +export interface SnodeFromSeed { + public_ip: string; + storage_port: number; + pubkey_x25519: string; + pubkey_ed25519: string; +} + +/** + * This call will try 4 times to contact a seed nodes (random) and get the snode list from it. + * If all attempts fails, this function will throw the last error. + * The returned list is not shuffled when returned. + */ +async function getSnodeListFromSeednode(seedNodes: Array): Promise> { + const SEED_NODE_RETRIES = 4; + + return pRetry( + async () => { + window?.log?.info('getSnodeListFromSeednode starting...'); + if (!seedNodes.length) { + window?.log?.info('loki_snode_api::getSnodeListFromSeednode - seedNodes are empty'); + throw new Error('getSnodeListFromSeednode - seedNodes are empty'); + } + // do not try/catch, we do want exception to bubble up so pRetry, well, retries + const snodes = await SeedNodeAPI.TEST_fetchSnodePoolFromSeedNodeRetryable(seedNodes); + + return snodes; + }, + { + retries: SEED_NODE_RETRIES - 1, + factor: 2, + minTimeout: SeedNodeAPI.getMinTimeout(), + onFailedAttempt: e => { + window?.log?.warn( + `fetchSnodePoolFromSeedNodeRetryable attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left... Error: ${e.message}` + ); + }, + } + ); +} + +export function getMinTimeout() { + return 1000; +} + +/** + * This functions choose randonly a seed node from seedNodes and try to get the snodes from it, or throws. + * This function is to be used with a pRetry caller + */ +export async function TEST_fetchSnodePoolFromSeedNodeRetryable( + seedNodes: Array +): Promise> { + window?.log?.info('fetchSnodePoolFromSeedNodeRetryable starting...'); + + if (!seedNodes.length) { + window?.log?.info('loki_snode_api::fetchSnodePoolFromSeedNodeRetryable - seedNodes are empty'); + throw new Error('fetchSnodePoolFromSeedNodeRetryable: Seed nodes are empty'); + } + + const seedNode = _.sample(seedNodes); + if (!seedNode) { + window?.log?.warn( + 'loki_snode_api::fetchSnodePoolFromSeedNodeRetryable - Could not select random snodes from', + seedNodes + ); + throw new Error('fetchSnodePoolFromSeedNodeRetryable: Seed nodes are empty #2'); + } + + const tryUrl = new URL(seedNode.url); + + const snodes = await getSnodesFromSeedUrl(tryUrl); + if (snodes.length === 0) { + window?.log?.warn( + `loki_snode_api::fetchSnodePoolFromSeedNodeRetryable - ${seedNode.url} did not return any snodes` + ); + throw new Error(`Failed to contact seed node: ${seedNode.url}`); + } + + return snodes; +} + +/** + * Try to get the snode list from the given seed node URL, or throws. + * This function throws for whatever reason might happen (timeout, invalid response, 0 valid snodes returned, ...) + * This function is to be used inside a pRetry function + */ +async function getSnodesFromSeedUrl(urlObj: URL): Promise> { + // Removed limit until there is a way to get snode info + // for individual nodes (needed for guard nodes); this way + // we get all active nodes + window?.log?.info(`getSnodesFromSeedUrl starting with ${urlObj.href}`); + + const params = { + active_only: true, + fields: { + public_ip: true, + storage_port: true, + pubkey_x25519: true, + pubkey_ed25519: true, + }, + }; + + const endpoint = 'json_rpc'; + const url = `${urlObj.href}${endpoint}`; + + const body = { + jsonrpc: '2.0', + id: '0', + method: 'get_n_service_nodes', + params, + }; + + const sslAgent = getSslAgentForSeedNode( + urlObj.hostname, + urlObj.protocol !== Constants.PROTOCOLS.HTTP + ); + + const fetchOptions = { + method: 'POST', + timeout: 5000, + body: JSON.stringify(body), + headers: { + 'User-Agent': 'WhatsApp', + 'Accept-Language': 'en-us', + }, + agent: sslAgent, + }; + window?.log?.info('insecureNodeFetch => plaintext for getSnodesFromSeedUrl'); + + const response = await insecureNodeFetch(url, fetchOptions); + + if (response.status !== 200) { + window?.log?.error( + `loki_snode_api:::getSnodesFromSeedUrl - invalid response from seed ${urlObj.toString()}:`, + response + ); + throw new Error( + `getSnodesFromSeedUrl: status is not 200 ${response.status} from ${urlObj.href}` + ); + } + + if (response.headers.get('Content-Type') !== 'application/json') { + window?.log?.error('Response is not json'); + throw new Error(`getSnodesFromSeedUrl: response is not json Content-Type from ${urlObj.href}`); + } + + try { + const json = await response.json(); + const result = json.result; + + if (!result) { + window?.log?.error( + `loki_snode_api:::getSnodesFromSeedUrl - invalid result from seed ${urlObj.toString()}:`, + response + ); + throw new Error(`getSnodesFromSeedUrl: json.result is empty from ${urlObj.href}`); + } + // Filter 0.0.0.0 nodes which haven't submitted uptime proofs + const validNodes = result.service_node_states.filter( + (snode: any) => snode.public_ip !== '0.0.0.0' + ); + + if (validNodes.length === 0) { + throw new Error(`Did not get a single valid snode from ${urlObj.href}`); + } + return validNodes; + } catch (e) { + window?.log?.error('Invalid json response'); + throw new Error(`getSnodesFromSeedUrl: cannot parse content as JSON from ${urlObj.href}`); + } +} diff --git a/ts/session/seed_node_api/index.ts b/ts/session/seed_node_api/index.ts new file mode 100644 index 0000000000..109c8846a4 --- /dev/null +++ b/ts/session/seed_node_api/index.ts @@ -0,0 +1,3 @@ +import * as SeedNodeAPI from './SeedNodeAPI'; + +export { SeedNodeAPI }; diff --git a/ts/session/snode_api/SNodeAPI.ts b/ts/session/snode_api/SNodeAPI.ts index 267097b234..767dbb95e1 100644 --- a/ts/session/snode_api/SNodeAPI.ts +++ b/ts/session/snode_api/SNodeAPI.ts @@ -1,24 +1,12 @@ -// we don't throw or catch here -import { default as insecureNodeFetch } from 'node-fetch'; -import https from 'https'; - -import fs from 'fs'; -import path from 'path'; -import tls from 'tls'; -import Electron from 'electron'; - -const { remote } = Electron; - import { snodeRpc } from './lokiRpc'; import { getRandomSnode, - getRandomSnodePool, getSwarmFor, + minSnodePoolCount, requiredSnodesForAgreement, } from './snodePool'; -import { Constants } from '..'; -import { getSodium, sha256 } from '../crypto'; +import { getSodium } from '../crypto'; import _, { range } from 'lodash'; import pRetry from 'p-retry'; import { @@ -32,6 +20,7 @@ import { Snode } from '../../data/data'; import { updateIsOnline } from '../../state/ducks/onion'; import { ed25519Str } from '../onions/onionPath'; import { StringUtils, UserUtils } from '../utils'; +import { SnodePool } from '.'; // ONS name can have [a-zA-Z0-9_-] except that - is not allowed as start or end // do not define a regex but rather create it on the fly to avoid https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time @@ -39,161 +28,6 @@ export const onsNameRegex = '^\\w([\\w-]*[\\w])?$'; export const ERROR_CODE_NO_CONNECT = 'ENETUNREACH: No network connection.'; -const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => { - let filePrefix = ''; - let pubkey256 = ''; - let cert256 = ''; - if (!isSsl) { - return undefined; - } - - switch (seedNodeHost) { - case 'storage.seed1.loki.network': - filePrefix = 'storage-seed-1'; - pubkey256 = 'JOsnIcAanVbgECNA8lHtC8f/cqN9m8EP7jKT6XCjeL8='; - cert256 = - '6E:2B:AC:F3:6E:C1:FF:FF:24:F3:CA:92:C6:94:81:B4:82:43:DF:C7:C6:03:98:B8:F5:6B:7D:30:7B:16:C1:CB'; - break; - case 'storage.seed3.loki.network': - filePrefix = 'storage-seed-3'; - pubkey256 = 'mMmZD3lG4Fi7nTC/EWzRVaU3bbCLsH6Ds2FHSTpo0Rk='; - cert256 = - '24:13:4C:0A:03:D8:42:A6:09:DE:35:76:F4:BD:FB:11:60:DB:F9:88:9F:98:46:B7:60:A6:60:0C:4C:CF:60:72'; - - break; - case 'public.loki.foundation': - filePrefix = 'public-loki-foundation'; - pubkey256 = 'W+Zv52qlcm1BbdpJzFwxZrE7kfmEboq7h3Dp/+Q3RPg='; - cert256 = - '40:E4:67:7D:18:6B:4D:08:8D:E9:D5:47:52:25:B8:28:E0:D3:63:99:9B:38:46:7D:92:19:5B:61:B9:AE:0E:EA'; - - break; - - default: - throw new Error(`Unknown seed node: ${seedNodeHost}`); - } - // tslint:disable: non-literal-fs-path - // read the cert each time. We only run this request once for each seed node nevertheless. - const appPath = remote.app.getAppPath(); - const crt = fs.readFileSync(path.join(appPath, `/certificates/${filePrefix}.crt`), 'utf-8'); - const sslOptions = { - // as the seed nodes are using a self signed certificate, we have to provide it here. - ca: crt, - // we have to reject them, otherwise our errors returned in the checkServerIdentity are simply not making the call fail. - // so in production, rejectUnauthorized must be true. - rejectUnauthorized: true, - keepAlive: false, - checkServerIdentity: (host: string, cert: any) => { - // Make sure the certificate is issued to the host we are connected to - const err = tls.checkServerIdentity(host, cert); - if (err) { - return err; - } - - // Pin the public key, similar to HPKP pin-sha25 pinning - if (sha256(cert.pubkey) !== pubkey256) { - const msg = - 'Certificate verification error: ' + - `The public key of '${cert.subject.CN}' ` + - 'does not match our pinned fingerprint'; - return new Error(msg); - } - - // Pin the exact certificate, rather than the pub key - if (cert.fingerprint256 !== cert256) { - const msg = - 'Certificate verification error: ' + - `The certificate of '${cert.subject.CN}' ` + - 'does not match our pinned fingerprint'; - return new Error(msg); - } - return undefined; - }, - }; - - // we're creating a new Agent that will now use the certs we have configured - return new https.Agent(sslOptions); -}; - -export async function getSnodesFromSeedUrl(urlObj: URL): Promise> { - // Removed limit until there is a way to get snode info - // for individual nodes (needed for guard nodes); this way - // we get all active nodes - window?.log?.info(`getSnodesFromSeedUrl starting with ${urlObj.href}`); - - const params = { - active_only: true, - fields: { - public_ip: true, - storage_port: true, - pubkey_x25519: true, - pubkey_ed25519: true, - }, - }; - - const endpoint = 'json_rpc'; - const url = `${urlObj.href}${endpoint}`; - - const body = { - jsonrpc: '2.0', - id: '0', - method: 'get_n_service_nodes', - params, - }; - - const sslAgent = getSslAgentForSeedNode( - urlObj.hostname, - urlObj.protocol !== Constants.PROTOCOLS.HTTP - ); - - const fetchOptions = { - method: 'POST', - timeout: 5000, - body: JSON.stringify(body), - headers: { - 'User-Agent': 'WhatsApp', - 'Accept-Language': 'en-us', - }, - agent: sslAgent, - }; - window?.log?.info('insecureNodeFetch => plaintext for getSnodesFromSeedUrl'); - - const response = await insecureNodeFetch(url, fetchOptions); - - if (response.status !== 200) { - window?.log?.error( - `loki_snode_api:::getSnodesFromSeedUrl - invalid response from seed ${urlObj.toString()}:`, - response - ); - return []; - } - - if (response.headers.get('Content-Type') !== 'application/json') { - window?.log?.error('Response is not json'); - return []; - } - - try { - const json = await response.json(); - - // TODO: validate that all of the fields are present? - const result = json.result; - - if (!result) { - window?.log?.error( - `loki_snode_api:::getSnodesFromSeedUrl - invalid result from seed ${urlObj.toString()}:`, - response - ); - return []; - } - // Filter 0.0.0.0 nodes which haven't submitted uptime proofs - return result.service_node_states.filter((snode: any) => snode.public_ip !== '0.0.0.0'); - } catch (e) { - window?.log?.error('Invalid json response'); - return []; - } -} - export type SendParams = { pubKey: string; ttl: string; @@ -210,7 +44,12 @@ async function requestSnodesForPubkeyWithTargetNodeRetryable( const params = { pubKey, }; - const result = await snodeRpc('get_snodes_for_pubkey', params, targetNode, pubKey); + const result = await snodeRpc({ + method: 'get_snodes_for_pubkey', + params, + targetNode, + associatedWith: pubKey, + }); if (!result) { window?.log?.warn( @@ -328,7 +167,7 @@ export async function getSessionIDForOnsName(onsNameCase: string) { // we do this request with validationCount snodes const promises = range(0, validationCount).map(async () => { const targetNode = await getRandomSnode(); - const result = await snodeRpc('oxend_request', params, targetNode); + const result = await snodeRpc({ method: 'oxend_request', params, targetNode }); if (!result || result.status !== 200 || !result.body) { throw new Error('ONSresolve:Failed to resolve ONS'); } @@ -436,22 +275,36 @@ export async function getSessionIDForOnsName(onsNameCase: string) { /** * Try to fetch from 3 different snodes an updated list of snodes. * If we get less than 24 common snodes in those result, we consider the request to failed and an exception is thrown. + * The three snode we make the request to is randomized. + * This function is to be called with a pRetry so that if one snode does not reply anything, another might be choose next time. * Return the list of nodes all snodes agreed on. */ export async function getSnodePoolFromSnodes() { - const existingSnodePool = await getRandomSnodePool(); - if (existingSnodePool.length < 3) { - window?.log?.warn('cannot get snodes from snodes; not enough snodes', existingSnodePool.length); - return; + const existingSnodePool = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); + if (existingSnodePool.length <= minSnodePoolCount) { + window?.log?.warn( + 'getSnodePoolFromSnodes: Cannot get snodes list from snodes; not enough snodes', + existingSnodePool.length + ); + throw new Error( + `Cannot get snodes list from snodes; not enough snodes even after refetching from seed', ${existingSnodePool.length}` + ); } // Note intersectionWith only works with 3 at most array to find the common snodes. const nodesToRequest = _.sampleSize(existingSnodePool, 3); const results = await Promise.all( nodesToRequest.map(async node => { - // this call is already retried if the snode does not reply - // at least when onion requests enabled - return getSnodePoolFromSnode(node); + /** + * this call is already retried if the snode does not reply + * (at least when onion requests are enabled) + * this request might want to rebuild a path if the snode length gets < minSnodePoolCount during the + * retries, so we need to make sure this does not happen. + * + * Remember that here, we are trying to fetch from snodes the updated list of snodes to rebuild a path. + * If we don't disable rebuilding a path below, this gets to a chicken and egg problem. + */ + return TEST_getSnodePoolFromSnode(node); }) ); @@ -466,21 +319,24 @@ export async function getSnodePoolFromSnodes() { ); // We want the snodes to agree on at least this many snodes if (commonSnodes.length < requiredSnodesForAgreement) { - throw new Error('inconsistentSnodePools'); + throw new Error( + `Inconsistent snode pools. We did not get at least ${requiredSnodesForAgreement} in common` + ); } return commonSnodes; } /** - * Returns a list of uniq snodes got from the specified targetNode. + * Returns a list of unique snodes got from the specified targetNode. + * This function won't try to rebuild a path if at some point we don't have enough snodes. * This is exported for testing purpose only */ -export async function getSnodePoolFromSnode(targetNode: Snode): Promise> { +// tslint:disable-next-line: function-name +export async function TEST_getSnodePoolFromSnode(targetNode: Snode): Promise> { const params = { endpoint: 'get_service_nodes', params: { active_only: true, - // limit: 256, fields: { public_ip: true, storage_port: true, @@ -489,7 +345,11 @@ export async function getSnodePoolFromSnode(targetNode: Snode): Promise; // we the return list by the snode is already made of uniq snodes @@ -528,7 +387,12 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis try { // no retry here. If an issue is with the path this is handled in lokiOnionFetch // if there is an issue with the targetNode, we still send a few times this request to a few snodes in // already so it's handled - const result = await snodeRpc('store', params, targetNode, params.pubKey); + const result = await snodeRpc({ + method: 'store', + params, + targetNode, + associatedWith: params.pubKey, + }); if (!result || result.status !== 200) { return false; @@ -558,7 +422,7 @@ export async function retrieveNextMessages( // let exceptions bubble up // no retry for this one as this a call we do every few seconds while polling for messages - const result = await snodeRpc('retrieve', params, targetNode, associatedWith); + const result = await snodeRpc({ method: 'retrieve', params, targetNode, associatedWith }); if (!result) { window?.log?.warn( @@ -601,7 +465,7 @@ export async function retrieveNextMessages( */ // tslint:disable-next-line: variable-name export const TEST_getNetworkTime = async (snode: Snode): Promise => { - const response: any = await snodeRpc('info', {}, snode); + const response: any = await snodeRpc({ method: 'info', params: {}, targetNode: snode }); const body = JSON.parse(response.body); const timestamp = body?.timestamp; if (!timestamp) { @@ -650,12 +514,12 @@ export const forceNetworkDeletion = async (): Promise | null> => { timestamp, signature: signatureBase64, }; - const ret = await snodeRpc( - 'delete_all', - deleteMessageParams, - snodeToMakeRequestTo, - userX25519PublicKey - ); + const ret = await snodeRpc({ + method: 'delete_all', + params: deleteMessageParams, + targetNode: snodeToMakeRequestTo, + associatedWith: userX25519PublicKey, + }); if (!ret) { throw new Error( diff --git a/ts/session/snode_api/lokiRpc.ts b/ts/session/snode_api/lokiRpc.ts index 5cf3320284..da13ad2c33 100644 --- a/ts/session/snode_api/lokiRpc.ts +++ b/ts/session/snode_api/lokiRpc.ts @@ -14,13 +14,17 @@ interface FetchOptions { * A small wrapper around node-fetch which deserializes response * returns insecureNodeFetch response or false */ -async function lokiFetch( - url: string, - options: FetchOptions, - targetNode?: Snode, - associatedWith?: string, - test?: string -): Promise { +async function lokiFetch({ + options, + url, + associatedWith, + targetNode, +}: { + url: string; + options: FetchOptions; + targetNode?: Snode; + associatedWith?: string; +}): Promise { const timeout = 10000; const method = options.method || 'GET'; @@ -38,7 +42,11 @@ async function lokiFetch( ? true : window.lokiFeatureFlags?.useOnionRequests; if (useOnionRequests && targetNode) { - const fetchResult = await lokiOnionFetch(targetNode, fetchOptions.body, associatedWith, test); + const fetchResult = await lokiOnionFetch({ + targetNode, + body: fetchOptions.body, + associatedWith, + }); if (!fetchResult) { return undefined; } @@ -84,10 +92,17 @@ async function lokiFetch( * The */ export async function snodeRpc( - method: string, - params: any, - targetNode: Snode, - associatedWith?: string //the user pubkey this call is for. if the onion request fails, this is used to handle the error for this user swarm for instance + { + method, + params, + targetNode, + associatedWith, + }: { + method: string; + params: any; + targetNode: Snode; + associatedWith?: string; + } //the user pubkey this call is for. if the onion request fails, this is used to handle the error for this user swarm for instance ): Promise { const url = `https://${targetNode.ip}:${targetNode.port}/storage_rpc/v1`; @@ -115,5 +130,10 @@ export async function snodeRpc( }, }; - return lokiFetch(url, fetchOptions, targetNode, associatedWith, method); + return lokiFetch({ + url, + options: fetchOptions, + targetNode, + associatedWith, + }); } diff --git a/ts/session/snode_api/onions.ts b/ts/session/snode_api/onions.ts index 102297f8b0..6be32565d6 100644 --- a/ts/session/snode_api/onions.ts +++ b/ts/session/snode_api/onions.ts @@ -14,6 +14,7 @@ let snodeFailureCount: Record = {}; import { Snode } from '../../data/data'; import { ERROR_CODE_NO_CONNECT } from './SNodeAPI'; +import { Onions } from '.'; export const resetSnodeFailureCount = () => { snodeFailureCount = {}; @@ -107,6 +108,9 @@ async function buildOnionCtxs( finalRelayOptions?: FinalRelayOptions ) { const ctxes = [destCtx]; + if (!nodePath) { + throw new Error('buildOnionCtxs needs a valid path'); + } // from (3) 2 to 0 const firstPos = nodePath.length - 1; @@ -194,6 +198,9 @@ async function buildOnionGuardNodePayload( return encodeCiphertextPlusJson(guardCtx.ciphertext, guardPayloadObj); } +/** + * 406 is a clock out of sync error + */ function process406Error(statusCode: number) { if (statusCode === 406) { // clock out of sync @@ -209,17 +216,18 @@ function processOxenServerError(_statusCode: number, body?: string) { } } +/** + * 421 is a invalid swarm error + */ async function process421Error( statusCode: number, body: string, - guardNodeEd25519: string, associatedWith?: string, lsrpcEd25519Key?: string ) { if (statusCode === 421) { await handle421InvalidSwarm({ snodeEd25519: lsrpcEd25519Key, - guardNodeEd25519, body, associatedWith, }); @@ -235,13 +243,11 @@ async function process421Error( async function processOnionRequestErrorAtDestination({ statusCode, body, - guardNodeEd25519, destinationEd25519, associatedWith, }: { statusCode: number; body: string; - guardNodeEd25519: string; destinationEd25519?: string; associatedWith?: string; }) { @@ -251,56 +257,58 @@ async function processOnionRequestErrorAtDestination({ window?.log?.info('processOnionRequestErrorAtDestination. statusCode nok:', statusCode); process406Error(statusCode); - await process421Error(statusCode, body, guardNodeEd25519, associatedWith, destinationEd25519); + await process421Error(statusCode, body, associatedWith, destinationEd25519); processOxenServerError(statusCode, body); if (destinationEd25519) { - await processAnyOtherErrorAtDestination( - statusCode, - body, - guardNodeEd25519, - destinationEd25519, - associatedWith - ); + await processAnyOtherErrorAtDestination(statusCode, body, destinationEd25519, associatedWith); } } +async function handleNodeNotFound({ + ed25519NotFound, + associatedWith, +}: { + ed25519NotFound: string; + associatedWith?: string; +}) { + const shortNodeNotFound = ed25519Str(ed25519NotFound); + window?.log?.warn('Handling NODE NOT FOUND with: ', shortNodeNotFound); + + if (associatedWith) { + await dropSnodeFromSwarmIfNeeded(associatedWith, ed25519NotFound); + } + + await dropSnodeFromSnodePool(ed25519NotFound); + snodeFailureCount[ed25519NotFound] = 0; + // try to remove the not found snode from any of the paths if it's there. + // it may not be here, as the snode note found might be the target snode of the request. + await OnionPaths.dropSnodeFromPath(ed25519NotFound); +} + async function processAnyOtherErrorOnPath( status: number, guardNodeEd25519: string, ciphertext?: string, associatedWith?: string ) { - // this test checks for on error in your path. - if ( - // response.status === 502 || - // response.status === 503 || - // response.status === 504 || - // response.status === 404 || - status !== 200 // this is pretty strong. a 400 (Oxen server error) will be handled as a bad path. - ) { + // this test checks for an error in your path. + if (status !== 200) { window?.log?.warn(`[path] Got status: ${status}`); - // - let nodeNotFound; - if (ciphertext?.startsWith(NEXT_NODE_NOT_FOUND_PREFIX)) { - nodeNotFound = ciphertext.substr(NEXT_NODE_NOT_FOUND_PREFIX.length); - } - - processOxenServerError(status, ciphertext); // If we have a specific node in fault we can exclude just this node. - // Otherwise we increment the whole path failure count - if (nodeNotFound) { - window?.log?.warn('node not found error with: ', ed25519Str(nodeNotFound)); - await exports.incrementBadSnodeCountOrDrop({ - snodeEd25519: nodeNotFound, - guardNodeEd25519, - associatedWith, - }); - + if (ciphertext?.startsWith(NEXT_NODE_NOT_FOUND_PREFIX)) { + const nodeNotFound = ciphertext.substr(NEXT_NODE_NOT_FOUND_PREFIX.length); // we are checking errors on the path, a nodeNotFound on the path should trigger a rebuild + + await handleNodeNotFound({ ed25519NotFound: nodeNotFound, associatedWith }); } else { + // Otherwise we increment the whole path failure count + await incrementBadPathCountOrDrop(guardNodeEd25519); } + + processOxenServerError(status, ciphertext); + throw new Error(`Bad Path handled. Retry this request. Status: ${status}`); } } @@ -308,7 +316,6 @@ async function processAnyOtherErrorOnPath( async function processAnyOtherErrorAtDestination( status: number, body: string, - guardNodeEd25519: string, destinationEd25519: string, associatedWith?: string ) { @@ -320,31 +327,23 @@ async function processAnyOtherErrorAtDestination( ) { window?.log?.warn(`[path] Got status at destination: ${status}`); - let nodeNotFound; if (body?.startsWith(NEXT_NODE_NOT_FOUND_PREFIX)) { - nodeNotFound = body.substr(NEXT_NODE_NOT_FOUND_PREFIX.length); - - if (nodeNotFound) { - await exports.incrementBadSnodeCountOrDrop({ - snodeEd25519: destinationEd25519, - guardNodeEd25519, - associatedWith, - }); - // if we get a nodeNotFound at the desitnation. it means the targetNode to which we made the request is not found. - // We have to retry with another targetNode so it's not just rebuilding the path. We have to go one lever higher (lokiOnionFetch). - // status is 502 for a node not found - throw new pRetry.AbortError( - `Bad Path handled. Retry this request with another targetNode. Status: ${status}` - ); - } + const nodeNotFound = body.substr(NEXT_NODE_NOT_FOUND_PREFIX.length); + // if we get a nodeNotFound at the destination. it means the targetNode to which we made the request is not found. + await handleNodeNotFound({ + ed25519NotFound: nodeNotFound, + associatedWith, + }); + + // We have to retry with another targetNode so it's not just rebuilding the path. We have to go one lever higher (lokiOnionFetch). + // status is 502 for a node not found + throw new pRetry.AbortError( + `Bad Path handled. Retry this request with another targetNode. Status: ${status}` + ); } - // If we have a specific node in fault we can exclude just this node. - // Otherwise we increment the whole path failure count - // if (nodeNotFound) { - await exports.incrementBadSnodeCountOrDrop({ + await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519: destinationEd25519, - guardNodeEd25519, associatedWith, }); @@ -363,13 +362,7 @@ async function processOnionRequestErrorOnPath( window?.log?.warn('errorONpath:', ciphertext); } process406Error(httpStatusCode); - await process421Error( - httpStatusCode, - ciphertext, - guardNodeEd25519, - associatedWith, - lsrpcEd25519Key - ); + await process421Error(httpStatusCode, ciphertext, associatedWith, lsrpcEd25519Key); await processAnyOtherErrorOnPath(httpStatusCode, guardNodeEd25519, ciphertext, associatedWith); } @@ -416,7 +409,6 @@ export async function processOnionResponse({ abortSignal, associatedWith, lsrpcEd25519Key, - test, }: { response?: { text: () => Promise; status: number }; symmetricKey?: ArrayBuffer; @@ -424,7 +416,6 @@ export async function processOnionResponse({ lsrpcEd25519Key?: string; abortSignal?: AbortSignal; associatedWith?: string; - test?: string; }): Promise { let ciphertext = ''; @@ -455,7 +446,7 @@ export async function processOnionResponse({ let ciphertextBuffer; try { - const decoded = await exports.decodeOnionResult(symmetricKey, ciphertext, test); + const decoded = await exports.decodeOnionResult(symmetricKey, ciphertext); plaintext = decoded.plaintext; ciphertextBuffer = decoded.ciphertextBuffer; @@ -492,7 +483,6 @@ export async function processOnionResponse({ await processOnionRequestErrorAtDestination({ statusCode: status, body: jsonRes?.body, // this is really important. the `.body`. the .body should be a string. for isntance for nodeNotFound but is most likely a dict (Record)) - guardNodeEd25519: guardNode.pubkey_ed25519, destinationEd25519: lsrpcEd25519Key, associatedWith, }); @@ -529,11 +519,9 @@ export type DestinationContext = { async function handle421InvalidSwarm({ body, snodeEd25519, - guardNodeEd25519, associatedWith, }: { body: string; - guardNodeEd25519: string; snodeEd25519?: string; associatedWith?: string; }) { @@ -571,7 +559,7 @@ async function handle421InvalidSwarm({ await dropSnodeFromSwarmIfNeeded(associatedWith, snodeEd25519); } } - await exports.incrementBadSnodeCountOrDrop({ snodeEd25519, guardNodeEd25519, associatedWith }); + await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519, associatedWith }); // this is important we throw so another retry is made and we exit the handling of that reponse throw new pRetry.AbortError(exceptionMessage); @@ -591,47 +579,26 @@ async function handle421InvalidSwarm({ */ export async function incrementBadSnodeCountOrDrop({ snodeEd25519, - guardNodeEd25519, associatedWith, }: { snodeEd25519: string; - guardNodeEd25519: string; associatedWith?: string; }) { - if (!guardNodeEd25519) { - window?.log?.warn('We need a guardNodeEd25519 at all times'); - } const oldFailureCount = snodeFailureCount[snodeEd25519] || 0; const newFailureCount = oldFailureCount + 1; snodeFailureCount[snodeEd25519] = newFailureCount; if (newFailureCount >= snodeFailureThreshold) { - window?.log?.warn(`Failure threshold reached for: ${ed25519Str(snodeEd25519)}; dropping it.`); + window?.log?.warn( + `Failure threshold reached for snode: ${ed25519Str(snodeEd25519)}; dropping it.` + ); if (associatedWith) { - window?.log?.warn( - `Dropping ${ed25519Str(snodeEd25519)} from swarm of ${ed25519Str(associatedWith)}` - ); await dropSnodeFromSwarmIfNeeded(associatedWith, snodeEd25519); } - window?.log?.info(`Dropping ${ed25519Str(snodeEd25519)} from snodepool`); - await dropSnodeFromSnodePool(snodeEd25519); - // the snode was ejected from the pool so it won't be used again. - // in case of snode pool refresh, we need to be able to try to contact this node again so reset its failure count to 0. snodeFailureCount[snodeEd25519] = 0; - try { - await OnionPaths.dropSnodeFromPath(snodeEd25519); - } catch (e) { - window?.log?.warn( - 'dropSnodeFromPath, got error while patching up... incrementing the whole path as bad', - e.message - ); - // If dropSnodeFromPath throws, it means there is an issue patching up the path, increment the whole path issues count - // but using the guardNode we got instead of the snodeEd25519. - // - await OnionPaths.incrementBadPathCountOrDrop(guardNodeEd25519); - } + await OnionPaths.dropSnodeFromPath(snodeEd25519); } else { window?.log?.warn( `Couldn't reach snode at: ${ed25519Str( @@ -652,7 +619,6 @@ export const sendOnionRequestHandlingSnodeEject = async ({ abortSignal, associatedWith, finalRelayOptions, - test, }: { nodePath: Array; destX25519Any: string; @@ -664,7 +630,6 @@ export const sendOnionRequestHandlingSnodeEject = async ({ finalRelayOptions?: FinalRelayOptions; abortSignal?: AbortSignal; associatedWith?: string; - test?: string; }): Promise => { // this sendOnionRequest() call has to be the only one like this. // If you need to call it, call it through sendOnionRequestHandlingSnodeEject because this is the one handling path rebuilding and known errors @@ -678,14 +643,21 @@ export const sendOnionRequestHandlingSnodeEject = async ({ finalDestOptions, finalRelayOptions, abortSignal, - test, }); response = result.response; + if ( + !_.isEmpty(finalRelayOptions) && + response.status === 502 && + response.statusText === 'Bad Gateway' + ) { + // it's an opengroup server and his is not responding. Consider this as a ENETUNREACH + throw new pRetry.AbortError('ENETUNREACH'); + } decodingSymmetricKey = result.decodingSymmetricKey; } catch (e) { - window.log.warn('sendOnionRequest', e); - if (e.code === 'ENETUNREACH') { + window?.log?.warn('sendOnionRequest error message: ', e.message); + if (e.code === 'ENETUNREACH' || e.message === 'ENETUNREACH') { throw e; } } @@ -698,7 +670,6 @@ export const sendOnionRequestHandlingSnodeEject = async ({ lsrpcEd25519Key: finalDestOptions?.destination_ed25519_hex, abortSignal, associatedWith, - test, }); return processed; @@ -731,7 +702,6 @@ const sendOnionRequest = async ({ }; finalRelayOptions?: FinalRelayOptions; abortSignal?: AbortSignal; - test?: string; }) => { // get destination pubkey in array buffer format let destX25519hex = destX25519Any; @@ -826,8 +796,7 @@ async function sendOnionRequestSnodeDest( onionPath: Array, targetNode: Snode, plaintext?: string, - associatedWith?: string, - test?: string + associatedWith?: string ) { return sendOnionRequestHandlingSnodeEject({ nodePath: onionPath, @@ -837,7 +806,6 @@ async function sendOnionRequestSnodeDest( body: plaintext, }, associatedWith, - test, }); } @@ -845,37 +813,30 @@ export function getPathString(pathObjArr: Array<{ ip: string; port: number }>): return pathObjArr.map(node => `${node.ip}:${node.port}`).join(', '); } -async function onionFetchRetryable( - targetNode: Snode, - body?: string, - associatedWith?: string, - test?: string -): Promise { - // Get a path excluding `targetNode`: - const path = await OnionPaths.getOnionPath(targetNode); - const result = await sendOnionRequestSnodeDest(path, targetNode, body, associatedWith, test); - return result; -} - /** * If the fetch throws a retryable error we retry this call with a new path at most 3 times. If another error happens, we return it. If we have a result we just return it. */ -export async function lokiOnionFetch( - targetNode: Snode, - body?: string, - associatedWith?: string, - test?: string -): Promise { +export async function lokiOnionFetch({ + targetNode, + associatedWith, + body, +}: { + targetNode: Snode; + body?: string; + associatedWith?: string; +}): Promise { try { const retriedResult = await pRetry( async () => { - return onionFetchRetryable(targetNode, body, associatedWith, test); + // Get a path excluding `targetNode`: + const path = await OnionPaths.getOnionPath({ toExclude: targetNode }); + const result = await sendOnionRequestSnodeDest(path, targetNode, body, associatedWith); + return result; }, { - retries: 4, + retries: 3, factor: 1, - minTimeout: 1000, - maxTimeout: 2000, + minTimeout: 100, onFailedAttempt: e => { window?.log?.warn( `onionFetchRetryable attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` diff --git a/ts/session/snode_api/snodePool.ts b/ts/session/snode_api/snodePool.ts index d8af6dc885..ec4226f573 100644 --- a/ts/session/snode_api/snodePool.ts +++ b/ts/session/snode_api/snodePool.ts @@ -1,15 +1,14 @@ import _ from 'lodash'; -import { getSnodePoolFromSnodes, getSnodesFromSeedUrl, requestSnodesForPubkey } from './SNodeAPI'; +import { getSnodePoolFromSnodes, requestSnodesForPubkey } from './SNodeAPI'; import * as Data from '../../../ts/data/data'; -import { allowOnlyOneAtATime } from '../utils/Promise'; import pRetry from 'p-retry'; import { ed25519Str } from '../onions/onionPath'; import { OnionPaths } from '../onions'; -import { Onions } from '.'; - +import { Onions, SnodePool } from '.'; +import { SeedNodeAPI } from '../seed_node_api'; /** * If we get less than this snode in a swarm, we fetch new snodes for this pubkey */ @@ -21,6 +20,11 @@ const minSwarmSnodeCount = 3; */ export const minSnodePoolCount = 12; +/** + * If we get less than this amount of snodes (24), lets try to get an updated list from those while we can + */ +export const minSnodePoolCountBeforeRefreshFromSnodes = minSnodePoolCount * 2; + /** * If we do a request to fetch nodes from snodes and they don't return at least * the same `requiredSnodesForAgreement` snodes we consider that this is not a valid return. @@ -29,65 +33,17 @@ export const minSnodePoolCount = 12; */ export const requiredSnodesForAgreement = 24; -// This should be renamed to `allNodes` or something -export let randomSnodePool: Array = []; +let randomSnodePool: Array = []; + +// tslint:disable-next-line: function-name +export function TEST_resetState() { + randomSnodePool = []; + swarmCache.clear(); +} // We only store nodes' identifiers here, const swarmCache: Map> = new Map(); -export type SeedNode = { - url: string; -}; - -// just get the filtered list -async function tryGetSnodeListFromLokidSeednode( - seedNodes: Array -): Promise> { - window?.log?.info('tryGetSnodeListFromLokidSeednode starting...'); - - if (!seedNodes.length) { - window?.log?.info('loki_snode_api::tryGetSnodeListFromLokidSeednode - seedNodes are empty'); - return []; - } - - const seedNode = _.sample(seedNodes); - if (!seedNode) { - window?.log?.warn( - 'loki_snode_api::tryGetSnodeListFromLokidSeednode - Could not select random snodes from', - seedNodes - ); - return []; - } - let snodes = []; - try { - const tryUrl = new URL(seedNode.url); - - snodes = await getSnodesFromSeedUrl(tryUrl); - // throw before clearing the lock, so the retries can kick in - if (snodes.length === 0) { - window?.log?.warn( - `loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.url} did not return any snodes` - ); - // does this error message need to be exactly this? - throw new window.textsecure.SeedNodeError('Failed to contact seed node'); - } - - return snodes; - } catch (e) { - window?.log?.warn( - 'LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - error', - e.code, - e.message, - 'on', - seedNode - ); - if (snodes.length === 0) { - throw new window.textsecure.SeedNodeError('Failed to contact seed node'); - } - } - return []; -} - /** * Drop a snode from the snode pool. This does not update the swarm containing this snode. * Use `dropSnodeFromSwarmIfNeeded` for that @@ -97,28 +53,34 @@ export async function dropSnodeFromSnodePool(snodeEd25519: string) { const exists = _.some(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519); if (exists) { _.remove(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519); - await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); - window?.log?.warn( - `Marking ${ed25519Str(snodeEd25519)} as unreachable, ${ + `Droppping ${ed25519Str(snodeEd25519)} from snode pool. ${ randomSnodePool.length } snodes remaining in randomPool` ); + await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); } } /** * - * @param excluding can be used to exclude some nodes from the random list. Useful to rebuild a path excluding existing node already in a path + * excludingEd25519Snode can be used to exclude some nodes from the random list. + * Useful to rebuild a path excluding existing node already in a path */ export async function getRandomSnode(excludingEd25519Snode?: Array): Promise { - // resolve random snode - if (randomSnodePool.length === 0) { - // Should not this be saved to the database? - await refreshRandomPool(); + // make sure we have a few snodes in the pool excluding the one passed as args + const requiredCount = minSnodePoolCount + (excludingEd25519Snode?.length || 0); + if (randomSnodePool.length < requiredCount) { + await getSnodePoolFromDBOrFetchFromSeed(excludingEd25519Snode?.length); - if (randomSnodePool.length === 0) { - throw new window.textsecure.SeedNodeError('Invalid seed node response'); + if (randomSnodePool.length < requiredCount) { + window?.log?.warn( + `getRandomSnode: failed to fetch snodes from seed. Current pool: ${randomSnodePool.length}` + ); + + throw new Error( + `getRandomSnode: failed to fetch snodes from seed. Current pool: ${randomSnodePool.length}, required count: ${requiredCount}` + ); } } // We know the pool can't be empty at this point @@ -131,211 +93,156 @@ export async function getRandomSnode(excludingEd25519Snode?: Array): Pro e => !excludingEd25519Snode.includes(e.pubkey_ed25519) ); if (!snodePoolExcluding || !snodePoolExcluding.length) { - if (window?.textsecure) { - throw new window.textsecure.SeedNodeError( - 'Not enough snodes with excluding length', - excludingEd25519Snode.length - ); - } // used for tests - throw new Error('SeedNodeError'); + throw new Error(`Not enough snodes with excluding length ${excludingEd25519Snode.length}`); } return _.sample(snodePoolExcluding) as Data.Snode; } /** - * This function force the snode poll to be refreshed from a random seed node again. + * This function force the snode poll to be refreshed from a random seed node or snodes if we have enough of them. * This should be called once in a day or so for when the app it kept on. */ export async function forceRefreshRandomSnodePool(): Promise> { - await refreshRandomPool(true); - - return randomSnodePool; -} + try { + await getSnodePoolFromDBOrFetchFromSeed(); -export async function getRandomSnodePool(): Promise> { - if (randomSnodePool.length === 0) { - await refreshRandomPool(); - } - return randomSnodePool; -} + window?.log?.info( + `forceRefreshRandomSnodePool: enough snodes to fetch from them, so we try using them ${randomSnodePool.length}` + ); -async function getSnodeListFromLokidSeednode( - seedNodes: Array, - retries = 0 -): Promise> { - const SEED_NODE_RETRIES = 3; - window?.log?.info('getSnodeListFromLokidSeednode starting...'); - if (!seedNodes.length) { - window?.log?.info('loki_snode_api::getSnodeListFromLokidSeednode - seedNodes are empty'); - return []; - } - let snodes: Array = []; - try { - snodes = await tryGetSnodeListFromLokidSeednode(seedNodes); + // this function throws if it does not have enough snodes to do it + await tryToGetConsensusWithSnodesWithRetries(); + if (randomSnodePool.length < minSnodePoolCountBeforeRefreshFromSnodes) { + throw new Error('forceRefreshRandomSnodePool still too small after refetching from snodes'); + } } catch (e) { - window?.log?.warn('loki_snode_api::getSnodeListFromLokidSeednode - error', e.code, e.message); - // handle retries in case of temporary hiccups - if (retries < SEED_NODE_RETRIES) { - setTimeout(async () => { - window?.log?.info( - 'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #', - retries, - 'seed nodes total', - seedNodes.length - ); - try { - await getSnodeListFromLokidSeednode(seedNodes, retries + 1); - } catch (e) { - window?.log?.warn('getSnodeListFromLokidSeednode failed retr y #', retries, e); - } - }, retries * retries * 5000); - } else { - window?.log?.error('loki_snode_api::getSnodeListFromLokidSeednode - failing'); - throw new window.textsecure.SeedNodeError('Failed to contact seed node'); + window?.log?.warn( + 'forceRefreshRandomSnodePool: Failed to fetch snode pool from snodes. Fetching from seed node instead:', + e.message + ); + + // if that fails to get enough snodes, even after retries, well we just have to retry later. + try { + await SnodePool.TEST_fetchFromSeedWithRetriesAndWriteToDb(); + } catch (e) { + window?.log?.warn( + 'forceRefreshRandomSnodePool: Failed to fetch snode pool from seed. Fetching from seed node instead:', + e.message + ); } } - return snodes; + + return randomSnodePool; } /** - * Fetch all snodes from a seed nodes if we don't have enough snodes to make the request ourself. - * Exported only for tests. This is not to be used by the app directly - * @param seedNodes the seednodes to use to fetch snodes details + * Fetches from DB if snode pool is not cached, and returns it if the length is >= 12. + * If length is < 12, fetches from seed an updated list of snodes */ -export async function refreshRandomPoolDetail( - seedNodes: Array +export async function getSnodePoolFromDBOrFetchFromSeed( + countToAddToRequirement = 0 ): Promise> { - let snodes = []; - try { - window?.log?.info(`refreshRandomPoolDetail with seedNodes.length ${seedNodes.length}`); - - snodes = await getSnodeListFromLokidSeednode(seedNodes); - // make sure order of the list is random, so we get version in a non-deterministic way - snodes = _.shuffle(snodes); - // commit changes to be live - // we'll update the version (in case they upgrade) every cycle - const fetchSnodePool = snodes.map((snode: any) => ({ - ip: snode.public_ip, - port: snode.storage_port, - pubkey_x25519: snode.pubkey_x25519, - pubkey_ed25519: snode.pubkey_ed25519, - version: '', - })); - window?.log?.info( - 'LokiSnodeAPI::refreshRandomPool - Refreshed random snode pool with', - snodes.length, - 'snodes' + if (randomSnodePool && randomSnodePool.length > minSnodePoolCount + countToAddToRequirement) { + return randomSnodePool; + } + const fetchedFromDb = await Data.getSnodePoolFromDb(); + + if (!fetchedFromDb || fetchedFromDb.length <= minSnodePoolCount + countToAddToRequirement) { + window?.log?.warn( + `getSnodePoolFromDBOrFetchFromSeed: not enough snodes in db (${fetchedFromDb?.length}), Fetching from seed node instead... ` ); - return fetchSnodePool; - } catch (e) { - window?.log?.warn('LokiSnodeAPI::refreshRandomPool - error', e.code, e.message); - /* - log.error( - 'LokiSnodeAPI:::refreshRandomPoolPromise - Giving up trying to contact seed node' - ); - */ - if (snodes.length === 0) { - throw new window.textsecure.SeedNodeError('Failed to contact seed node'); - } - return []; + // if that fails to get enough snodes, even after retries, well we just have to retry later. + // this call does not throw + await SnodePool.TEST_fetchFromSeedWithRetriesAndWriteToDb(); + + return randomSnodePool; } + + // write to memory only if it is valid. + randomSnodePool = fetchedFromDb; + return randomSnodePool; +} + +export async function getRandomSnodePool(): Promise> { + if (randomSnodePool.length <= minSnodePoolCount) { + await getSnodePoolFromDBOrFetchFromSeed(); + } + return randomSnodePool; } + /** - * This function runs only once at a time, and fetches the snode pool from a random seed node, - * or if we have enough snodes, fetches the snode pool from one of the snode. + * This function tries to fetch snodes list from seednodes and handle retries. + * It will write the updated snode list to the db once it succeeded. + * It also resets the onionpaths failure count and snode failure count. + * This function does not throw. */ -export async function refreshRandomPool(forceRefresh = false): Promise { +// tslint:disable: function-name +export async function TEST_fetchFromSeedWithRetriesAndWriteToDb() { const seedNodes = window.getSeedNodeList(); if (!seedNodes || !seedNodes.length) { window?.log?.error( - 'LokiSnodeAPI:::refreshRandomPool - getSeedNodeList has not been loaded yet' + 'LokiSnodeAPI:::fetchFromSeedWithRetriesAndWriteToDb - getSeedNodeList has not been loaded yet' ); return; } - window?.log?.info("right before allowOnlyOneAtATime 'refreshRandomPool'"); - - return allowOnlyOneAtATime('refreshRandomPool', async () => { - window?.log?.info("running allowOnlyOneAtATime 'refreshRandomPool'"); - - // if we have forceRefresh set, we want to request snodes from snodes or from the seed server. - if (randomSnodePool.length === 0 && !forceRefresh) { - const fetchedFromDb = await Data.getSnodePoolFromDb(); - // write to memory only if it is valid. - // if the size is not enough. we will contact a seed node. - if (fetchedFromDb?.length) { - window?.log?.info(`refreshRandomPool: fetched from db ${fetchedFromDb.length} snodes.`); - randomSnodePool = fetchedFromDb; - if (randomSnodePool.length <= minSnodePoolCount) { - window?.log?.warn('refreshRandomPool: not enough snodes in db, going to fetch from seed'); - } else { - return; - } - } else { - window?.log?.warn('refreshRandomPool: did not find snodes in db.'); - } - } + try { + randomSnodePool = await SeedNodeAPI.fetchSnodePoolFromSeedNodeWithRetries(seedNodes); + await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); - // we don't have nodes to fetch the pool from them, so call the seed node instead. - if (randomSnodePool.length <= minSnodePoolCount) { - window?.log?.info( - `refreshRandomPool: NOT enough snodes to fetch from them ${randomSnodePool.length} <= ${minSnodePoolCount}, so falling back to seedNodes ${seedNodes?.length}` - ); + OnionPaths.resetPathFailureCount(); + Onions.resetSnodeFailureCount(); + } catch (e) { + window?.log?.error( + 'LokiSnodeAPI:::fetchFromSeedWithRetriesAndWriteToDb - Failed to fetch snode poll from seed node with retries. Error:', + e + ); + } +} - randomSnodePool = await exports.refreshRandomPoolDetail(seedNodes); - await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); - return; - } - try { +/** + * This function retries a few times to get a consensus between 3 snodes of at least 24 snodes in the snode pool. + * + * If a consensus cannot be made, this function throws an error and the caller needs to call the fetch snodes from seed. + * + */ +async function tryToGetConsensusWithSnodesWithRetries() { + // let this request try 4 (3+1) times. If all those requests end up without having a consensus, + // fetch the snode pool from one of the seed nodes (see the catch). + return pRetry( + async () => { + const commonNodes = await getSnodePoolFromSnodes(); + + if (!commonNodes || commonNodes.length < requiredSnodesForAgreement) { + // throwing makes trigger a retry if we have some left. + window?.log?.info( + `tryToGetConsensusWithSnodesWithRetries: Not enough common nodes ${commonNodes?.length}` + ); + throw new Error('Not enough common nodes.'); + } window?.log?.info( - `refreshRandomPool: enough snodes to fetch from them, so we try using them ${randomSnodePool.length}` - ); - - // let this request try 3 (3+1) times. If all those requests end up without having a consensus, - // fetch the snode pool from one of the seed nodes (see the catch). - await pRetry( - async () => { - const commonNodes = await getSnodePoolFromSnodes(); - - if (!commonNodes || commonNodes.length < requiredSnodesForAgreement) { - // throwing makes trigger a retry if we have some left. - window?.log?.info(`refreshRandomPool: Not enough common nodes ${commonNodes?.length}`); - throw new Error('Not enough common nodes.'); - } - window?.log?.info('updating snode list with snode pool length:', commonNodes.length); - randomSnodePool = commonNodes; - OnionPaths.resetPathFailureCount(); - Onions.resetSnodeFailureCount(); - - await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); - }, - { - retries: 3, - factor: 1, - minTimeout: 1000, - onFailedAttempt: e => { - window?.log?.warn( - `getSnodePoolFromSnodes attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` - ); - }, - } + 'Got consensus: updating snode list with snode pool length:', + commonNodes.length ); - } catch (e) { - window?.log?.warn( - 'Failed to fetch snode pool from snodes. Fetching from seed node instead:', - e - ); - - // fallback to a seed node fetch of the snode pool - randomSnodePool = await exports.refreshRandomPoolDetail(seedNodes); + randomSnodePool = commonNodes; + await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); OnionPaths.resetPathFailureCount(); Onions.resetSnodeFailureCount(); - await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); + }, + { + retries: 3, + factor: 1, + minTimeout: 1000, + onFailedAttempt: e => { + window?.log?.warn( + `tryToGetConsensusWithSnodesWithRetries attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` + ); + }, } - }); + ); } /** @@ -348,6 +255,10 @@ export async function dropSnodeFromSwarmIfNeeded( snodeToDropEd25519: string ): Promise { // this call either used the cache or fetch the swarm from the db + window?.log?.warn( + `Dropping ${ed25519Str(snodeToDropEd25519)} from swarm of ${ed25519Str(pubkey)}` + ); + const existingSwarm = await getSwarmFromCacheOrDb(pubkey); if (!existingSwarm.includes(snodeToDropEd25519)) { diff --git a/ts/session/snode_api/swarmPolling.ts b/ts/session/snode_api/swarmPolling.ts index 0486878d3a..0b250ed3fc 100644 --- a/ts/session/snode_api/swarmPolling.ts +++ b/ts/session/snode_api/swarmPolling.ts @@ -286,7 +286,7 @@ export class SwarmPolling { }, { minTimeout: 100, - retries: 2, + retries: 1, onFailedAttempt: e => { window?.log?.warn( `retrieveNextMessages attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` diff --git a/ts/session/utils/Promise.ts b/ts/session/utils/Promise.ts index 8d1ec73b9d..49d0e765ae 100644 --- a/ts/session/utils/Promise.ts +++ b/ts/session/utils/Promise.ts @@ -56,7 +56,7 @@ export async function allowOnlyOneAtATime( } // tslint:disable-next-line: no-dynamic-delete delete snodeGlobalLocks[name]; // clear lock - throw e; + reject(e); } // clear timeout timer if (timeoutMs) { diff --git a/ts/test/session/unit/onion/GuardNodes_test.ts b/ts/test/session/unit/onion/GuardNodes_test.ts new file mode 100644 index 0000000000..eac41a06bf --- /dev/null +++ b/ts/test/session/unit/onion/GuardNodes_test.ts @@ -0,0 +1,196 @@ +// tslint:disable: no-implicit-dependencies max-func-body-length no-unused-expression + +import chai from 'chai'; +import * as sinon from 'sinon'; +import _ from 'lodash'; +import { describe } from 'mocha'; + +import { TestUtils } from '../../../test-utils'; +import { Onions, SnodePool } from '../../../../session/snode_api'; +import * as Data from '../../../../data/data'; + +import chaiAsPromised from 'chai-as-promised'; +import * as OnionPaths from '../../../../session/onions/onionPath'; +import { generateFakeSnodes, generateFakeSnodeWithEdKey } from '../../../test-utils/utils'; +import { SeedNodeAPI } from '../../../../session/seed_node_api'; +chai.use(chaiAsPromised as any); +chai.should(); + +const { expect } = chai; + +const guard1ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534e'; +const guard2ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f91615349'; +const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534a'; + +const fakeSnodePool: Array = [ + ...generateFakeSnodes(12), + generateFakeSnodeWithEdKey(guard1ed), + generateFakeSnodeWithEdKey(guard2ed), + generateFakeSnodeWithEdKey(guard3ed), + ...generateFakeSnodes(3), +]; + +// tslint:disable: variable-name + +// tslint:disable-next-line: max-func-body-length +describe('GuardNodes', () => { + // Initialize new stubbed cache + const sandbox = sinon.createSandbox(); + let getSnodePoolFromDBOrFetchFromSeed: sinon.SinonStub; + let fetchFromSeedWithRetriesAndWriteToDb: sinon.SinonStub; + describe('selectGuardNodes', () => { + beforeEach(() => { + OnionPaths.clearTestOnionPath(); + + TestUtils.stubWindowLog(); + TestUtils.stubWindow('getGlobalOnlineStatus', () => true); + + Onions.resetSnodeFailureCount(); + OnionPaths.resetPathFailureCount(); + SnodePool.TEST_resetState(); + }); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + }); + + it('does not fetch from seed if we got 12 or more snodes in the db', async () => { + sandbox.stub(Data, 'getSnodePoolFromDb').resolves(fakeSnodePool); + + getSnodePoolFromDBOrFetchFromSeed = sandbox + .stub(SnodePool, 'getSnodePoolFromDBOrFetchFromSeed') + .callThrough(); + fetchFromSeedWithRetriesAndWriteToDb = sandbox + .stub(SnodePool, 'TEST_fetchFromSeedWithRetriesAndWriteToDb') + .resolves(); + const testGuardNode = sandbox.stub(OnionPaths, 'TEST_testGuardNode').resolves(true); + + sandbox.stub(Data, 'updateGuardNodes').resolves(); + // run the command + const fetchedGuardNodes = await OnionPaths.selectGuardNodes(); + + expect( + getSnodePoolFromDBOrFetchFromSeed.callCount, + 'getSnodePoolFromDBOrFetchFromSeed should have been called' + ).to.be.eq(1); + expect( + fetchFromSeedWithRetriesAndWriteToDb.callCount, + 'fetchFromSeedWithRetriesAndWriteToDb should not have been called' + ).to.be.eq(0); + expect( + testGuardNode.callCount, + 'firstGuardNode should have been called three times' + ).to.be.eq(3); + const firstGuardNode = testGuardNode.firstCall.args[0]; + const secondGuardNode = testGuardNode.secondCall.args[0]; + const thirdGuardNode = testGuardNode.thirdCall.args[0]; + expect(fetchedGuardNodes).to.deep.equal([firstGuardNode, secondGuardNode, thirdGuardNode]); + }); + + it('throws an error if we got enough snodes in the db but none test passes', async () => { + sandbox.stub(Data, 'getSnodePoolFromDb').resolves(fakeSnodePool); + + getSnodePoolFromDBOrFetchFromSeed = sandbox + .stub(SnodePool, 'getSnodePoolFromDBOrFetchFromSeed') + .callThrough(); + fetchFromSeedWithRetriesAndWriteToDb = sandbox + .stub(SnodePool, 'TEST_fetchFromSeedWithRetriesAndWriteToDb') + .resolves(); + const testGuardNode = sandbox.stub(OnionPaths, 'TEST_testGuardNode').resolves(false); + + sandbox.stub(Data, 'updateGuardNodes').resolves(); + // run the command + let throwedError: string | undefined; + try { + await OnionPaths.selectGuardNodes(); + } catch (e) { + throwedError = e.message; + } + + expect( + getSnodePoolFromDBOrFetchFromSeed.callCount, + 'getSnodePoolFromDBOrFetchFromSeed should have been called' + ).to.be.eq(1); + expect( + fetchFromSeedWithRetriesAndWriteToDb.callCount, + 'fetchFromSeedWithRetriesAndWriteToDb should not have been called' + ).to.be.eq(0); + expect( + testGuardNode.callCount, + 'firstGuardNode should have been called three times' + ).to.be.eq(18); + expect(throwedError).to.be.equal('selectGuardNodes stopping after attempts: 6'); + }); + + it('throws an error if we have to fetch from seed, fetch from seed enough snode but we still fail', async () => { + const invalidSndodePool = fakeSnodePool.slice(0, 11); + sandbox.stub(Data, 'getSnodePoolFromDb').resolves(invalidSndodePool); + TestUtils.stubWindow('getSeedNodeList', () => [{ url: 'whatever' }]); + + getSnodePoolFromDBOrFetchFromSeed = sandbox + .stub(SnodePool, 'getSnodePoolFromDBOrFetchFromSeed') + .callThrough(); + fetchFromSeedWithRetriesAndWriteToDb = sandbox + .stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries') + .resolves(fakeSnodePool); + + sandbox.stub(Data, 'updateGuardNodes').resolves(); + // run the command + let throwedError: string | undefined; + try { + await OnionPaths.selectGuardNodes(); + } catch (e) { + throwedError = e.message; + } + + expect(throwedError).to.be.equal('selectGuardNodes stopping after attempts: 6'); + }); + + it('returns valid guardnode if we have to fetch from seed, fetch from seed enough snodes but guard node tests passes', async () => { + const invalidSndodePool = fakeSnodePool.slice(0, 11); + sandbox.stub(Data, 'getSnodePoolFromDb').resolves(invalidSndodePool); + TestUtils.stubWindow('getSeedNodeList', () => [{ url: 'whatever' }]); + const testGuardNode = sandbox.stub(OnionPaths, 'TEST_testGuardNode').resolves(true); + + getSnodePoolFromDBOrFetchFromSeed = sandbox + .stub(SnodePool, 'getSnodePoolFromDBOrFetchFromSeed') + .callThrough(); + fetchFromSeedWithRetriesAndWriteToDb = sandbox + .stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries') + .resolves(fakeSnodePool); + + sandbox.stub(Data, 'updateGuardNodes').resolves(); + // run the command + const guardNodes = await OnionPaths.selectGuardNodes(); + + expect(guardNodes.length).to.be.equal(3); + expect(testGuardNode.callCount).to.be.equal(3); + }); + + it('throws if we have to fetch from seed, fetch from seed but not have enough fetched snodes', async () => { + const invalidSndodePool = fakeSnodePool.slice(0, 11); + sandbox.stub(Data, 'getSnodePoolFromDb').resolves(invalidSndodePool); + TestUtils.stubWindow('getSeedNodeList', () => [{ url: 'whatever' }]); + + getSnodePoolFromDBOrFetchFromSeed = sandbox + .stub(SnodePool, 'getSnodePoolFromDBOrFetchFromSeed') + .callThrough(); + fetchFromSeedWithRetriesAndWriteToDb = sandbox + .stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries') + .resolves(invalidSndodePool); + + sandbox.stub(Data, 'updateGuardNodes').resolves(); + // run the command + let throwedError: string | undefined; + try { + await OnionPaths.selectGuardNodes(); + } catch (e) { + throwedError = e.message; + } + expect(throwedError).to.be.equal( + 'Could not select guard nodes. Not enough nodes in the pool: 11' + ); + }); + }); +}); diff --git a/ts/test/session/unit/onion/OnionErrors_test.ts b/ts/test/session/unit/onion/OnionErrors_test.ts index 4f9e13963f..30db105ed7 100644 --- a/ts/test/session/unit/onion/OnionErrors_test.ts +++ b/ts/test/session/unit/onion/OnionErrors_test.ts @@ -17,6 +17,7 @@ import { import AbortController from 'abort-controller'; import * as Data from '../../../../../ts/data/data'; import { pathFailureCount } from '../../../../session/onions/onionPath'; +import { SeedNodeAPI } from '../../../../session/seed_node_api'; chai.use(chaiAsPromised as any); chai.should(); @@ -67,7 +68,7 @@ describe('OnionPathsErrors', () => { beforeEach(async () => { guardPubkeys = TestUtils.generateFakePubKeys(3).map(n => n.key); - otherNodesPubkeys = TestUtils.generateFakePubKeys(13).map(n => n.key); + otherNodesPubkeys = TestUtils.generateFakePubKeys(20).map(n => n.key); SNodeAPI.Onions.resetSnodeFailureCount(); @@ -78,7 +79,6 @@ describe('OnionPathsErrors', () => { port: fakePortCurrent, pubkey_ed25519: ed25519, pubkey_x25519: ed25519, - version: '', }; }); guardSnode1 = guardNodesArray[0]; @@ -90,7 +90,6 @@ describe('OnionPathsErrors', () => { port: fakePortCurrent, pubkey_ed25519: ed25519, pubkey_x25519: ed25519, - version: '', }; }); @@ -100,14 +99,14 @@ describe('OnionPathsErrors', () => { fakeSwarmForAssociatedWith = otherNodesPubkeys.slice(0, 6); // Stubs sandbox.stub(OnionPaths, 'selectGuardNodes').resolves(guardNodesArray); - sandbox.stub(SNodeAPI.SNodeAPI, 'getSnodePoolFromSnode').resolves(guardNodesArray); + sandbox.stub(SNodeAPI.SNodeAPI, 'TEST_getSnodePoolFromSnode').resolves(guardNodesArray); TestUtils.stubData('getGuardNodes').resolves([ guardPubkeys[0], guardPubkeys[1], guardPubkeys[2], ]); TestUtils.stubWindow('getSeedNodeList', () => ['seednode1']); - sandbox.stub(SNodeAPI.SnodePool, 'refreshRandomPoolDetail').resolves(fakeSnodePool); + sandbox.stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries').resolves(fakeSnodePool); sandbox.stub(Data, 'getSwarmNodesForPubkey').resolves(fakeSwarmForAssociatedWith); updateGuardNodesStub = sandbox.stub(Data, 'updateGuardNodes').resolves(); @@ -128,7 +127,7 @@ describe('OnionPathsErrors', () => { OnionPaths.resetPathFailureCount(); - await OnionPaths.getOnionPath(); + await OnionPaths.getOnionPath({}); oldOnionPaths = OnionPaths.TEST_getTestOnionPath(); sandbox @@ -280,7 +279,6 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(1); expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, - guardNodeEd25519: guardSnode1.pubkey_ed25519, associatedWith, }); }); @@ -325,7 +323,6 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(1); expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, - guardNodeEd25519: guardSnode1.pubkey_ed25519, associatedWith, }); }); @@ -363,7 +360,6 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(1); expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, - guardNodeEd25519: guardSnode1.pubkey_ed25519, associatedWith, }); }); @@ -403,7 +399,6 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(1); expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, - guardNodeEd25519: guardSnode1.pubkey_ed25519, associatedWith, }); }); @@ -470,19 +465,25 @@ describe('OnionPathsErrors', () => { expect(e.name).to.not.equal('AbortError'); } expect(updateSwarmSpy.callCount).to.eq(0); - // now we make sure that this bad snode was dropped from this pubkey's swarm - expect(dropSnodeFromSwarmIfNeededSpy.callCount).to.eq(0); - // this specific node failed just once - expect(dropSnodeFromSnodePool.callCount).to.eq(0); - expect(dropSnodeFromPathSpy.callCount).to.eq(0); - expect(incrementBadPathCountOrDropSpy.callCount).to.eq(0); - expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(1); - expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ - snodeEd25519: failingSnode.pubkey_ed25519, - guardNodeEd25519: guardSnode1.pubkey_ed25519, - associatedWith, - }); + // this specific node failed just once but it was a node not found error. Force drop it + expect( + dropSnodeFromSwarmIfNeededSpy.callCount, + 'dropSnodeFromSwarmIfNeededSpy should have been called' + ).to.eq(1); + expect( + dropSnodeFromSnodePool.callCount, + 'dropSnodeFromSnodePool should have been called' + ).to.eq(1); + expect(dropSnodeFromPathSpy.callCount, 'dropSnodeFromPath should have been called').to.eq(1); + expect( + incrementBadPathCountOrDropSpy.callCount, + 'incrementBadPathCountOrDrop should not have been called' + ).to.eq(0); + expect( + incrementBadSnodeCountOrDropSpy.callCount, + 'incrementBadSnodeCountOrDrop should not have been called' + ).to.eq(0); }); it('throws a retryable error on 502 on last snode', async () => { @@ -505,20 +506,23 @@ describe('OnionPathsErrors', () => { expect(e.name).to.not.equal('AbortError'); } expect(updateSwarmSpy.callCount).to.eq(0); - // now we make sure that this bad snode was dropped from this pubkey's swarm - expect(dropSnodeFromSwarmIfNeededSpy.callCount).to.eq(0); - // this specific node failed just once - expect(dropSnodeFromSnodePool.callCount).to.eq(0); - expect(dropSnodeFromPathSpy.callCount).to.eq(0); - // we have a specific failing node so don't increment path errors - expect(incrementBadPathCountOrDropSpy.callCount).to.eq(0); - expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(1); - expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ - snodeEd25519: failingSnode.pubkey_ed25519, - guardNodeEd25519: guardSnode1.pubkey_ed25519, - associatedWith, - }); + // this specific node failed just once but it was a node not found error. Force drop it + expect(dropSnodeFromSwarmIfNeededSpy.callCount).to.eq(1); + + expect( + dropSnodeFromSnodePool.callCount, + 'dropSnodeFromSnodePool should have been called' + ).to.eq(1); + expect(dropSnodeFromPathSpy.callCount, 'dropSnodeFromPath should have been called').to.eq(1); + expect( + incrementBadPathCountOrDropSpy.callCount, + 'incrementBadPathCountOrDrop should not have been called' + ).to.eq(0); + expect( + incrementBadSnodeCountOrDropSpy.callCount, + 'incrementBadSnodeCountOrDrop should not have been called' + ).to.eq(0); }); it('drop a snode from pool, swarm and path if it keep failing', async () => { @@ -545,34 +549,23 @@ describe('OnionPathsErrors', () => { expect(updateSwarmSpy.callCount).to.eq(0); // now we make sure that this bad snode was dropped from this pubkey's swarm - expect(dropSnodeFromSwarmIfNeededSpy.callCount).to.eq(1); + expect(dropSnodeFromSwarmIfNeededSpy.callCount).to.eq(3); expect(dropSnodeFromSwarmIfNeededSpy.firstCall.args[0]).to.eq(associatedWith); expect(dropSnodeFromSwarmIfNeededSpy.firstCall.args[1]).to.eq(failingSnode.pubkey_ed25519); - // this specific node failed just once - expect(dropSnodeFromSnodePool.callCount).to.eq(1); - expect(dropSnodeFromSnodePool.firstCall.args[0]).to.eq(failingSnode.pubkey_ed25519); - expect(dropSnodeFromPathSpy.callCount).to.eq(1); - expect(dropSnodeFromPathSpy.firstCall.args[0]).to.eq(failingSnode.pubkey_ed25519); - - // we expect incrementBadSnodeCountOrDropSpy to be called three times with the same failing snode as we know who it is - expect(incrementBadSnodeCountOrDropSpy.callCount).to.eq(3); - expect(incrementBadSnodeCountOrDropSpy.args[0][0]).to.deep.eq({ - snodeEd25519: failingSnode.pubkey_ed25519, - guardNodeEd25519: guardSnode1.pubkey_ed25519, - associatedWith, - }); - expect(incrementBadSnodeCountOrDropSpy.args[1][0]).to.deep.eq({ - snodeEd25519: failingSnode.pubkey_ed25519, - guardNodeEd25519: guardSnode1.pubkey_ed25519, - associatedWith, - }); - expect(incrementBadSnodeCountOrDropSpy.args[2][0]).to.deep.eq({ - snodeEd25519: failingSnode.pubkey_ed25519, - guardNodeEd25519: guardSnode1.pubkey_ed25519, - associatedWith, - }); - expect(incrementBadPathCountOrDropSpy.callCount).to.eq(0); + expect( + dropSnodeFromSnodePool.callCount, + 'dropSnodeFromSnodePool should have been called' + ).to.eq(3); + expect(dropSnodeFromPathSpy.callCount, 'dropSnodeFromPath should have been called').to.eq(3); + expect( + incrementBadPathCountOrDropSpy.callCount, + 'incrementBadPathCountOrDrop should not have been called' + ).to.eq(0); + expect( + incrementBadSnodeCountOrDropSpy.callCount, + 'incrementBadSnodeCountOrDrop should not have been called' + ).to.eq(0); }); }); it('drop a path if it keep failing without a specific node in fault', async () => { @@ -612,7 +605,6 @@ describe('OnionPathsErrors', () => { for (let index = 0; index < 6; index++) { expect(incrementBadSnodeCountOrDropSpy.args[index][0]).to.deep.eq({ snodeEd25519: oldOnionPaths[0][(index % 2) + 1].pubkey_ed25519, - guardNodeEd25519: guardNode.pubkey_ed25519, }); } diff --git a/ts/test/session/unit/onion/OnionPaths_test.ts b/ts/test/session/unit/onion/OnionPaths_test.ts index 765915f829..bb923550e4 100644 --- a/ts/test/session/unit/onion/OnionPaths_test.ts +++ b/ts/test/session/unit/onion/OnionPaths_test.ts @@ -7,11 +7,13 @@ import { describe } from 'mocha'; import { TestUtils } from '../../../test-utils'; import * as SNodeAPI from '../../../../session/snode_api'; +import * as Data from '../../../../../ts/data/data'; import chaiAsPromised from 'chai-as-promised'; import * as OnionPaths from '../../../../session/onions/onionPath'; import { Snode } from '../../../../data/data'; import { generateFakeSnodes, generateFakeSnodeWithEdKey } from '../../../test-utils/utils'; +import { SeedNodeAPI } from '../../../../session/seed_node_api'; chai.use(chaiAsPromised as any); chai.should(); @@ -26,11 +28,16 @@ const fakeSnodePool: Array = [ generateFakeSnodeWithEdKey(guard1ed), generateFakeSnodeWithEdKey(guard2ed), generateFakeSnodeWithEdKey(guard3ed), - ...generateFakeSnodes(3), + ...generateFakeSnodes(9), ]; const fakeGuardNodesEd25519 = [guard1ed, guard2ed, guard3ed]; const fakeGuardNodes = fakeSnodePool.filter(m => fakeGuardNodesEd25519.includes(m.pubkey_ed25519)); +const fakeGuardNodesFromDB: Array = fakeGuardNodesEd25519.map(ed25519PubKey => { + return { + ed25519PubKey, + }; +}); // tslint:disable-next-line: max-func-body-length describe('OnionPaths', () => { @@ -38,38 +45,39 @@ describe('OnionPaths', () => { const sandbox = sinon.createSandbox(); let oldOnionPaths: Array>; - beforeEach(async () => { - // Utils Stubs - OnionPaths.clearTestOnionPath(); - - sandbox.stub(OnionPaths, 'selectGuardNodes').resolves(fakeGuardNodes); - sandbox.stub(SNodeAPI.SNodeAPI, 'getSnodePoolFromSnode').resolves(fakeGuardNodes); - TestUtils.stubData('getGuardNodes').resolves(fakeGuardNodesEd25519); - TestUtils.stubData('createOrUpdateItem').resolves(); - TestUtils.stubWindow('getSeedNodeList', () => ['seednode1']); - // tslint:disable: no-void-expression no-console - - TestUtils.stubWindowLog(); - - sandbox.stub(SNodeAPI.SnodePool, 'refreshRandomPoolDetail').resolves(fakeSnodePool); - SNodeAPI.Onions.resetSnodeFailureCount(); - OnionPaths.resetPathFailureCount(); - // get a copy of what old ones look like - await OnionPaths.getOnionPath(); - - oldOnionPaths = OnionPaths.TEST_getTestOnionPath(); - if (oldOnionPaths.length !== 3) { - throw new Error(`onion path length not enough ${oldOnionPaths.length}`); - } - // this just triggers a build of the onionPaths - }); - - afterEach(() => { - TestUtils.restoreStubs(); - sandbox.restore(); - }); - describe('dropSnodeFromPath', () => { + beforeEach(async () => { + // Utils Stubs + OnionPaths.clearTestOnionPath(); + + sandbox.stub(OnionPaths, 'selectGuardNodes').resolves(fakeGuardNodes); + sandbox.stub(SNodeAPI.SNodeAPI, 'TEST_getSnodePoolFromSnode').resolves(fakeGuardNodes); + sandbox.stub(Data, 'getSnodePoolFromDb').resolves(fakeSnodePool); + + TestUtils.stubData('getGuardNodes').resolves(fakeGuardNodesFromDB); + TestUtils.stubData('createOrUpdateItem').resolves(); + TestUtils.stubWindow('getSeedNodeList', () => ['seednode1']); + // tslint:disable: no-void-expression no-console + + TestUtils.stubWindowLog(); + + sandbox.stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries').resolves(fakeSnodePool); + SNodeAPI.Onions.resetSnodeFailureCount(); + OnionPaths.resetPathFailureCount(); + // get a copy of what old ones look like + await OnionPaths.getOnionPath({}); + + oldOnionPaths = OnionPaths.TEST_getTestOnionPath(); + if (oldOnionPaths.length !== 3) { + throw new Error(`onion path length not enough ${oldOnionPaths.length}`); + } + // this just triggers a build of the onionPaths + }); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + }); describe('with valid snode pool', () => { it('rebuilds after removing last snode on path', async () => { await OnionPaths.dropSnodeFromPath(oldOnionPaths[2][2].pubkey_ed25519); diff --git a/ts/test/session/unit/onion/SeedNodeAPI_test.ts b/ts/test/session/unit/onion/SeedNodeAPI_test.ts new file mode 100644 index 0000000000..e07079a0db --- /dev/null +++ b/ts/test/session/unit/onion/SeedNodeAPI_test.ts @@ -0,0 +1,93 @@ +// tslint:disable: no-implicit-dependencies max-func-body-length no-unused-expression + +import chai from 'chai'; +import * as sinon from 'sinon'; +import _ from 'lodash'; +import { describe } from 'mocha'; + +import { TestUtils } from '../../../test-utils'; +import { Onions, SnodePool } from '../../../../session/snode_api'; +import * as Data from '../../../../data/data'; + +import chaiAsPromised from 'chai-as-promised'; +import * as OnionPaths from '../../../../session/onions/onionPath'; +import { generateFakeSnodes, generateFakeSnodeWithEdKey } from '../../../test-utils/utils'; +import { SeedNodeAPI } from '../../../../session/seed_node_api'; +import { SnodeFromSeed } from '../../../../session/seed_node_api/SeedNodeAPI'; +chai.use(chaiAsPromised as any); +chai.should(); + +const { expect } = chai; + +const guard1ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534e'; +const guard2ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f91615349'; +const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534a'; + +const fakeSnodePool: Array = [ + ...generateFakeSnodes(12), + generateFakeSnodeWithEdKey(guard1ed), + generateFakeSnodeWithEdKey(guard2ed), + generateFakeSnodeWithEdKey(guard3ed), + ...generateFakeSnodes(3), +]; + +const fakeSnodePoolFromSeedNode: Array = fakeSnodePool.map(m => { + return { + public_ip: m.ip, + storage_port: m.port, + pubkey_x25519: m.pubkey_x25519, + pubkey_ed25519: m.pubkey_ed25519, + }; +}); +// tslint:disable: variable-name + +// tslint:disable-next-line: max-func-body-length +describe('SeedNodeAPI', () => { + // Initialize new stubbed cache + const sandbox = sinon.createSandbox(); + + describe('getSnodeListFromSeednode', () => { + beforeEach(() => { + // Utils Stubs + OnionPaths.clearTestOnionPath(); + + TestUtils.stubWindowLog(); + + Onions.resetSnodeFailureCount(); + OnionPaths.resetPathFailureCount(); + SnodePool.TEST_resetState(); + }); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + }); + + it('if the cached snode pool has less than 12 snodes, trigger a fetch from the seed nodes with retries', async () => { + const TEST_fetchSnodePoolFromSeedNodeRetryable = sandbox + .stub(SeedNodeAPI, 'TEST_fetchSnodePoolFromSeedNodeRetryable') + .onFirstCall() + .throws() + .onSecondCall() + .resolves(fakeSnodePoolFromSeedNode); + + sandbox.stub(SeedNodeAPI, 'getMinTimeout').returns(20); + + // run the command + const fetched = await SeedNodeAPI.fetchSnodePoolFromSeedNodeWithRetries([ + { url: 'seednode1' }, + ]); + + const sortedFetch = fetched.sort((a, b) => (a.pubkey_ed25519 > b.pubkey_ed25519 ? -1 : 1)); + const sortedFakeSnodePool = fakeSnodePool.sort((a, b) => + a.pubkey_ed25519 > b.pubkey_ed25519 ? -1 : 1 + ); + expect(sortedFetch).to.deep.equal(sortedFakeSnodePool); + + expect( + TEST_fetchSnodePoolFromSeedNodeRetryable.callCount, + 'TEST_fetchSnodePoolFromSeedNodeRetryable called twice as the first one failed' + ).to.be.eq(2); + }); + }); +}); diff --git a/ts/test/session/unit/onion/SnodePoolUpdate_test.ts b/ts/test/session/unit/onion/SnodePoolUpdate_test.ts new file mode 100644 index 0000000000..73f5719e49 --- /dev/null +++ b/ts/test/session/unit/onion/SnodePoolUpdate_test.ts @@ -0,0 +1,101 @@ +// tslint:disable: no-implicit-dependencies max-func-body-length no-unused-expression + +import chai from 'chai'; +import * as sinon from 'sinon'; +import _ from 'lodash'; +import { describe } from 'mocha'; + +import { TestUtils } from '../../../test-utils'; +import { Onions, SnodePool } from '../../../../session/snode_api'; +import * as Data from '../../../../data/data'; + +import chaiAsPromised from 'chai-as-promised'; +import * as OnionPaths from '../../../../session/onions/onionPath'; +import { generateFakeSnodes, generateFakeSnodeWithEdKey } from '../../../test-utils/utils'; +import { SeedNodeAPI } from '../../../../session/seed_node_api'; +chai.use(chaiAsPromised as any); +chai.should(); + +const { expect } = chai; + +const guard1ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534e'; +const guard2ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f91615349'; +const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534a'; + +const fakeSnodePool: Array = [ + ...generateFakeSnodes(12), + generateFakeSnodeWithEdKey(guard1ed), + generateFakeSnodeWithEdKey(guard2ed), + generateFakeSnodeWithEdKey(guard3ed), + ...generateFakeSnodes(3), +]; +// tslint:disable: variable-name + +// tslint:disable-next-line: max-func-body-length +describe('OnionPaths', () => { + // Initialize new stubbed cache + const sandbox = sinon.createSandbox(); + + describe('getSnodePoolFromDBOrFetchFromSeed', () => { + let getSnodePoolFromDb: sinon.SinonStub; + let fetchFromSeedWithRetriesAndWriteToDb: sinon.SinonStub; + let fetchSnodePoolFromSeedNodeWithRetries: sinon.SinonStub; + + beforeEach(() => { + // Utils Stubs + OnionPaths.clearTestOnionPath(); + + TestUtils.stubWindow('getSeedNodeList', () => ['seednode1']); + TestUtils.stubWindowLog(); + + Onions.resetSnodeFailureCount(); + OnionPaths.resetPathFailureCount(); + SnodePool.TEST_resetState(); + }); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + }); + it('if the cached snode pool has at least 12 snodes, just return it without fetching from seed', async () => { + getSnodePoolFromDb = sandbox.stub(Data, 'getSnodePoolFromDb').resolves(fakeSnodePool); + fetchFromSeedWithRetriesAndWriteToDb = sandbox.stub( + SnodePool, + 'TEST_fetchFromSeedWithRetriesAndWriteToDb' + ); + + const fetched = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); + expect(getSnodePoolFromDb.callCount).to.be.eq(1); + expect(fetchFromSeedWithRetriesAndWriteToDb.callCount).to.be.eq(0); + + expect(fetched).to.deep.equal(fakeSnodePool); + }); + + it('if the cached snode pool 12 or less snodes, trigger a fetch from the seed nodes', async () => { + const length12 = fakeSnodePool.slice(0, 12); + expect(length12.length).to.eq(12); + getSnodePoolFromDb = sandbox.stub(Data, 'getSnodePoolFromDb').resolves(length12); + + sandbox.stub(Data, 'updateSnodePoolOnDb').resolves(); + fetchFromSeedWithRetriesAndWriteToDb = sandbox + .stub(SnodePool, 'TEST_fetchFromSeedWithRetriesAndWriteToDb') + .callThrough(); + fetchSnodePoolFromSeedNodeWithRetries = sandbox + .stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries') + .resolves(fakeSnodePool); + + // run the command + const fetched = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); + expect(getSnodePoolFromDb.callCount).to.be.eq(1); + expect( + fetchFromSeedWithRetriesAndWriteToDb.callCount, + 'fetchFromSeedWithRetriesAndWriteToDb eq 1' + ).to.be.eq(1); + expect( + fetchSnodePoolFromSeedNodeWithRetries.callCount, + 'fetchSnodePoolFromSeedNodeWithRetries eq 1' + ).to.be.eq(1); + expect(fetched).to.deep.equal(fakeSnodePool); + }); + }); +});