From 58abd08e6dc18a837e302a16e8d243a9d9e7955f Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 12 May 2021 10:34:53 +1000 Subject: [PATCH] Get snode from snode (#1614) * force deleteAccount after 10sec timeout waiting for configMessage * move some constants to file where they are used * add a way to fetch snodes from snodes * remove a snode from a pubkey's swarm if we get 421 without valid content * remove getVersion from snodes * hide groupMembers in right panel for non-group convo --- libtextsecure/errors.js | 11 + libtextsecure/index.d.ts | 1 + preload.js | 5 - ts/components/MainViewController.tsx | 5 +- .../conversation/InviteContactsDialog.tsx | 3 +- .../conversation/SessionRightPanel.tsx | 2 +- .../opengroupV2/OpenGroupManagerV2.ts | 3 +- ts/session/constants.ts | 5 + .../conversations/ConversationController.ts | 4 +- ts/session/onions/index.ts | 22 +- ts/session/sending/LokiMessageApi.ts | 5 +- ts/session/snode_api/lokiRpc.ts | 10 +- ts/session/snode_api/onions.ts | 13 +- ts/session/snode_api/serviceNodeAPI.ts | 259 ++++++++++++------ ts/session/snode_api/snodePool.ts | 148 ++++++---- ts/session/snode_api/swarmPolling.ts | 4 +- ts/session/utils/syncUtils.ts | 5 + ts/window.d.ts | 4 - 18 files changed, 318 insertions(+), 191 deletions(-) diff --git a/libtextsecure/errors.js b/libtextsecure/errors.js index 6d0359b9f..393656e90 100644 --- a/libtextsecure/errors.js +++ b/libtextsecure/errors.js @@ -59,6 +59,17 @@ } inherit(ReplayableError, EmptySwarmError); + function InvalidateSwarm(number, message) { + // eslint-disable-next-line prefer-destructuring + this.number = number.split('.')[0]; + + ReplayableError.call(this, { + name: 'InvalidateSwarm', + message, + }); + } + inherit(ReplayableError, InvalidateSwarm); + function NotFoundError(message, error) { this.name = 'NotFoundError'; this.message = message; diff --git a/libtextsecure/index.d.ts b/libtextsecure/index.d.ts index cee0ea3af..99fc971e8 100644 --- a/libtextsecure/index.d.ts +++ b/libtextsecure/index.d.ts @@ -7,6 +7,7 @@ export interface LibTextsecure { SendMessageNetworkError: any; ReplayableError: any; EmptySwarmError: any; + InvalidateSwarm: any; SeedNodeError: any; HTTPError: any; NotFoundError: any; diff --git a/preload.js b/preload.js index 32cca1af0..f3a9437e0 100644 --- a/preload.js +++ b/preload.js @@ -58,7 +58,6 @@ window.lokiFeatureFlags = { useOnionRequests: true, useFileOnionRequests: true, useFileOnionRequestsV2: true, // more compact encoding of files in response - onionRequestHops: 3, useRequestEncryptionKeyPair: false, padOutgoingAttachments: true, }; @@ -83,8 +82,6 @@ window.isBeforeVersion = (toCheck, baseVersion) => { // eslint-disable-next-line func-names window.CONSTANTS = new (function() { - this.MAX_GROUP_NAME_LENGTH = 64; - this.CLOSED_GROUP_SIZE_LIMIT = 100; // Number of seconds to turn on notifications after reconnect/start of app this.NOTIFICATION_ENABLE_TIMEOUT_SECONDS = 10; @@ -94,8 +91,6 @@ window.CONSTANTS = new (function() { // Conforms to naming rules here // https://loki.network/2020/03/25/loki-name-system-the-facts/ this.LNS_REGEX = `^[a-zA-Z0-9_]([a-zA-Z0-9_-]{0,${this.LNS_MAX_LENGTH - 2}}[a-zA-Z0-9_]){0,1}$`; - this.MIN_GUARD_COUNT = 2; - this.DESIRED_GUARD_COUNT = 3; })(); window.versionInfo = { diff --git a/ts/components/MainViewController.tsx b/ts/components/MainViewController.tsx index 99e0d140f..9c222c93f 100644 --- a/ts/components/MainViewController.tsx +++ b/ts/components/MainViewController.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { ContactType } from './session/SessionMemberListItem'; import { ToastUtils } from '../session/utils'; import { createClosedGroup as createClosedGroupV2 } from '../receiver/closedGroups'; +import { VALIDATION } from '../session/constants'; export class MessageView extends React.Component { public render() { @@ -44,7 +45,7 @@ async function createClosedGroup( ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooShort')); return false; - } else if (groupName.length > window.CONSTANTS.MAX_GROUP_NAME_LENGTH) { + } else if (groupName.length > VALIDATION.MAX_GROUP_NAME_LENGTH) { ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooLong')); return false; } @@ -55,7 +56,7 @@ async function createClosedGroup( if (groupMembers.length < 1) { ToastUtils.pushToastError('pickClosedGroupMember', window.i18n('pickClosedGroupMember')); return false; - } else if (groupMembers.length >= window.CONSTANTS.CLOSED_GROUP_SIZE_LIMIT) { + } else if (groupMembers.length >= VALIDATION.CLOSED_GROUP_SIZE_LIMIT) { ToastUtils.pushToastError('closedGroupMaxSize', window.i18n('closedGroupMaxSize')); return false; } diff --git a/ts/components/conversation/InviteContactsDialog.tsx b/ts/components/conversation/InviteContactsDialog.tsx index 18c3dc678..684b9aee6 100644 --- a/ts/components/conversation/InviteContactsDialog.tsx +++ b/ts/components/conversation/InviteContactsDialog.tsx @@ -11,6 +11,7 @@ import { ConversationModel, ConversationTypeEnum } from '../../models/conversati import { getCompleteUrlForV2ConvoId } from '../../interactions/conversation'; import _ from 'lodash'; import autoBind from 'auto-bind'; +import { VALIDATION } from '../../session/constants'; interface Props { contactList: Array; chatName: string; @@ -151,7 +152,7 @@ class InviteContactsDialogInner extends React.Component { // be sure to include current zombies in this count if ( newMembers.length + existingMembers.length + existingZombies.length > - window.CONSTANTS.CLOSED_GROUP_SIZE_LIMIT + VALIDATION.CLOSED_GROUP_SIZE_LIMIT ) { ToastUtils.pushTooManyMembers(); return; diff --git a/ts/components/session/conversation/SessionRightPanel.tsx b/ts/components/session/conversation/SessionRightPanel.tsx index 349db3f0c..3530ae30a 100644 --- a/ts/components/session/conversation/SessionRightPanel.tsx +++ b/ts/components/session/conversation/SessionRightPanel.tsx @@ -252,7 +252,7 @@ class SessionRightPanel extends React.Component { const showUpdateGroupNameButton = isAdmin && !commonNoShow; const showAddRemoveModeratorsButton = isAdmin && !commonNoShow && isPublic; - const showUpdateGroupMembersButton = !isPublic && !commonNoShow; + const showUpdateGroupMembersButton = !isPublic && isGroup && !commonNoShow; return (
diff --git a/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts b/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts index 60aed126d..d649f6e42 100644 --- a/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts +++ b/ts/opengroup/opengroupV2/OpenGroupManagerV2.ts @@ -204,7 +204,8 @@ export class OpenGroupManagerV2 { } catch (e) { window.log.warn('Failed to join open group v2', e); await removeV2OpenGroupRoom(conversationId); - throw new Error(window.i18n('connectToServerFail')); + // throw new Error(window.i18n('connectToServerFail')); + return undefined; } } } diff --git a/ts/session/constants.ts b/ts/session/constants.ts index 314e5b174..0a04440f6 100644 --- a/ts/session/constants.ts +++ b/ts/session/constants.ts @@ -25,6 +25,11 @@ export const CONVERSATION = { MAX_ATTACHMENT_FILESIZE_BYTES: 6 * 1000 * 1000, }; +export const VALIDATION = { + MAX_GROUP_NAME_LENGTH: 64, + CLOSED_GROUP_SIZE_LIMIT: 100, +}; + export const UI = { // Pixels (scroll) from the top of the top of message container // at which more messages should be loaded diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index c23a1cfaf..93b6516a1 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -11,7 +11,7 @@ import { ConversationTypeEnum, } from '../../models/conversation'; import { BlockedNumberController } from '../../util'; -import { getSnodesFor } from '../snode_api/snodePool'; +import { getSwarm } from '../snode_api/snodePool'; import { PubKey } from '../types'; import { actions as conversationActions } from '../../state/ducks/conversations'; import { getV2OpenGroupRoom, removeV2OpenGroupRoom } from '../../data/opengroups'; @@ -120,7 +120,7 @@ export class ConversationController { await Promise.all([ conversation.updateProfileAvatar(), // NOTE: we request snodes updating the cache, but ignore the result - void getSnodesFor(id), + void getSwarm(id), ]); } }); diff --git a/ts/session/onions/index.ts b/ts/session/onions/index.ts index 8ac3d491d..fbd1d633d 100644 --- a/ts/session/onions/index.ts +++ b/ts/session/onions/index.ts @@ -8,6 +8,8 @@ import { allowOnlyOneAtATime } from '../utils/Promise'; export type Snode = SnodePool.Snode; +const desiredGuardCount = 3; +const minimumGuardCount = 2; interface SnodePath { path: Array; bad: boolean; @@ -15,7 +17,7 @@ interface SnodePath { export class OnionPaths { private static instance: OnionPaths | null; - + private static readonly onionRequestHops = 3; private onionPaths: Array = []; // This array is meant to store nodes will full info, @@ -46,7 +48,7 @@ export class OnionPaths { let goodPaths = this.onionPaths.filter(x => !x.bad); let attemptNumber = 0; - while (goodPaths.length < CONSTANTS.MIN_GUARD_COUNT) { + while (goodPaths.length < minimumGuardCount) { log.error( `Must have at least 2 good onion paths, actual: ${goodPaths.length}, attempt #${attemptNumber} fetching more...` ); @@ -174,11 +176,11 @@ export class OnionPaths { } private async selectGuardNodes(): Promise> { - const { CONSTANTS, log } = window; + const { log } = window; // `getRandomSnodePool` is expected to refresh itself on low nodes const nodePool = await SnodePool.getRandomSnodePool(); - if (nodePool.length < CONSTANTS.DESIRED_GUARD_COUNT) { + if (nodePool.length < desiredGuardCount) { log.error('Could not select guard nodes. Not enough nodes in the pool: ', nodePool.length); return []; } @@ -191,12 +193,12 @@ export class OnionPaths { // we only want to repeat if the await fails // eslint-disable-next-line-no-await-in-loop while (guardNodes.length < 3) { - if (shuffled.length < CONSTANTS.DESIRED_GUARD_COUNT) { + if (shuffled.length < desiredGuardCount) { log.error('Not enought nodes in the pool'); break; } - const candidateNodes = shuffled.splice(0, CONSTANTS.DESIRED_GUARD_COUNT); + const candidateNodes = shuffled.splice(0, desiredGuardCount); // Test all three nodes at once // eslint-disable-next-line no-await-in-loop @@ -209,7 +211,7 @@ export class OnionPaths { guardNodes = _.concat(guardNodes, goodNodes); } - if (guardNodes.length < CONSTANTS.DESIRED_GUARD_COUNT) { + if (guardNodes.length < desiredGuardCount) { log.error(`COULD NOT get enough guard nodes, only have: ${guardNodes.length}`); } @@ -223,7 +225,7 @@ export class OnionPaths { } private async buildNewOnionPathsWorker() { - const { CONSTANTS, log } = window; + const { log } = window; log.info('LokiSnodeAPI::buildNewOnionPaths - building new onion paths'); @@ -250,7 +252,7 @@ export class OnionPaths { } // If guard nodes is still empty (the old nodes are now invalid), select new ones: - if (this.guardNodes.length < CONSTANTS.MIN_GUARD_COUNT) { + if (this.guardNodes.length < minimumGuardCount) { // TODO: don't throw away potentially good guard nodes this.guardNodes = await this.selectGuardNodes(); } @@ -272,7 +274,7 @@ export class OnionPaths { const guards = _.shuffle(this.guardNodes); // Create path for every guard node: - const nodesNeededPerPaths = window.lokiFeatureFlags.onionRequestHops - 1; + const nodesNeededPerPaths = OnionPaths.onionRequestHops - 1; // Each path needs X (nodesNeededPerPaths) nodes in addition to the guard node: const maxPath = Math.floor( diff --git a/ts/session/sending/LokiMessageApi.ts b/ts/session/sending/LokiMessageApi.ts index 7c0b6dc82..15cf0a3d8 100644 --- a/ts/session/sending/LokiMessageApi.ts +++ b/ts/session/sending/LokiMessageApi.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { Snode } from '../onions'; import { SendParams, storeOnNode } from '../snode_api/serviceNodeAPI'; -import { getSnodesFor } from '../snode_api/snodePool'; +import { getSwarm } from '../snode_api/snodePool'; import { firstTrue } from '../utils/Promise'; const DEFAULT_CONNECTIONS = 3; @@ -46,7 +46,7 @@ export async function sendMessage( const data64 = window.dcodeIO.ByteBuffer.wrap(data).toString('base64'); // Using timestamp as a unique identifier - const swarm = await getSnodesFor(pubKey); + const swarm = await getSwarm(pubKey); // send parameters const params = { @@ -62,7 +62,6 @@ export async function sendMessage( let snode; try { - // eslint-disable-next-line more/no-then snode = await firstTrue(promises); } catch (e) { const snodeStr = snode ? `${snode.ip}:${snode.port}` : 'null'; diff --git a/ts/session/snode_api/lokiRpc.ts b/ts/session/snode_api/lokiRpc.ts index 9376917c2..a3b8ce726 100644 --- a/ts/session/snode_api/lokiRpc.ts +++ b/ts/session/snode_api/lokiRpc.ts @@ -14,7 +14,7 @@ async function lokiFetch( url: string, options: FetchOptions, targetNode?: Snode -): Promise { +): Promise { const timeout = 10000; const method = options.method || 'GET'; @@ -28,7 +28,11 @@ async function lokiFetch( // Absence of targetNode indicates that we want a direct connection // (e.g. to connect to a seed node for the first time) if (window.lokiFeatureFlags.useOnionRequests && targetNode) { - return await lokiOnionFetch(fetchOptions.body, targetNode); + const fetchResult = await lokiOnionFetch(fetchOptions.body, targetNode); + if (!fetchResult) { + return undefined; + } + return fetchResult; } if (url.match(/https:\/\//)) { @@ -62,7 +66,7 @@ export async function snodeRpc( method: string, params: any, targetNode: Snode -): Promise { +): Promise { const url = `https://${targetNode.ip}:${targetNode.port}/storage_rpc/v1`; // TODO: The jsonrpc and body field will be ignored on storage server diff --git a/ts/session/snode_api/onions.ts b/ts/session/snode_api/onions.ts index 944a38e3f..28dbb8e43 100644 --- a/ts/session/snode_api/onions.ts +++ b/ts/session/snode_api/onions.ts @@ -1,9 +1,8 @@ -import { default as insecureNodeFetch } from 'node-fetch'; +import { default as insecureNodeFetch, Response } from 'node-fetch'; import https from 'https'; import { Snode } from './snodePool'; import ByteBuffer from 'bytebuffer'; -import { StringUtils } from '../utils'; import { OnionPaths } from '../onions'; import { fromBase64ToArrayBuffer, toHex } from '../utils/String'; @@ -190,7 +189,7 @@ async function buildOnionGuardNodePayload( // May return false BAD_PATH, indicating that we should try a new path. const processOnionResponse = async ( reqIdx: number, - response: any, + response: Response, symmetricKey: ArrayBuffer, debug: boolean, abortSignal?: AbortSignal @@ -231,6 +230,7 @@ const processOnionResponse = async ( if (response.status !== 200) { const rsp = await response.text(); + log.warn( `(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}: ${rsp}` ); @@ -241,7 +241,7 @@ const processOnionResponse = async ( return RequestError.BAD_PATH; } - let ciphertext = (await response.text()) as string; + let ciphertext = await response.text(); if (!ciphertext) { log.warn( `(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext` @@ -492,10 +492,7 @@ function getPathString(pathObjArr: Array): string { return pathObjArr.map(node => `${node.ip}:${node.port}`).join(', '); } -export async function lokiOnionFetch( - body: any, - targetNode: Snode -): Promise { +export async function lokiOnionFetch(body: any, targetNode: Snode): Promise { const { log } = window; // Loop until the result is not BAD_PATH diff --git a/ts/session/snode_api/serviceNodeAPI.ts b/ts/session/snode_api/serviceNodeAPI.ts index 2d4813824..4d366f61c 100644 --- a/ts/session/snode_api/serviceNodeAPI.ts +++ b/ts/session/snode_api/serviceNodeAPI.ts @@ -15,59 +15,22 @@ import { sendOnionRequestLsrpcDest, snodeHttpsAgent, SnodeResponse } from './oni export { sendOnionRequestLsrpcDest }; -import { getRandomSnodeAddress, markNodeUnreachable, Snode, updateSnodesFor } from './snodePool'; +import { + getRandomSnodeAddress, + getRandomSnodePool, + getSwarm, + markNodeUnreachable, + requiredSnodesForAgreement, + Snode, + updateSnodesFor, +} from './snodePool'; import { Constants } from '..'; import { sleepFor } from '../utils/Promise'; import { sha256 } from '../crypto'; +import pRetry from 'p-retry'; +import _ from 'lodash'; -/** - * Currently unused. If we need it again, be sure to update it to onion routing rather - * than using a plain nodeFetch - */ -export async function getVersion(node: Snode, retries: number = 0): Promise { - const SNODE_VERSION_RETRIES = 3; - - const { log } = window; - - try { - window.log.warn('insecureNodeFetch => plaintext for getVersion'); - const result = await insecureNodeFetch(`https://${node.ip}:${node.port}/get_stats/v1`, { - agent: snodeHttpsAgent, - }); - const data = await result.json(); - if (data.version) { - return data.version; - } else { - return false; - } - } catch (e) { - // ECONNREFUSED likely means it's just offline... - // ECONNRESET seems to retry and fail as ECONNREFUSED (so likely a node going offline) - // ETIMEDOUT not sure what to do about these - // retry for now but maybe we should be marking bad... - if (e.code === 'ECONNREFUSED') { - markNodeUnreachable(node); - // clean up these error messages to be a little neater - log.warn(`LokiSnodeAPI::_getVersion - ${node.ip}:${node.port} is offline, removing`); - // if not ECONNREFUSED, it's mostly ECONNRESETs - // ENOTFOUND could mean no internet or hiccup - } else if (retries < SNODE_VERSION_RETRIES) { - log.warn( - 'LokiSnodeAPI::_getVersion - Error', - e.code, - e.message, - `on ${node.ip}:${node.port} retrying in 1s` - ); - await sleepFor(1000); - return getVersion(node, retries + 1); - } else { - markNodeUnreachable(node); - log.warn(`LokiSnodeAPI::_getVersion - failing to get version for ${node.ip}:${node.port}`); - } - // maybe throw? - return false; - } -} +const maxAcceptableFailuresStoreOnNode = 10; const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => { let filePrefix = ''; @@ -235,39 +198,37 @@ export type SendParams = { export async function requestSnodesForPubkey(pubKey: string): Promise> { const { log } = window; - let snode; + let targetNode; try { - snode = await getRandomSnodeAddress(); + targetNode = await getRandomSnodeAddress(); const result = await snodeRpc( 'get_snodes_for_pubkey', { pubKey, }, - snode + targetNode ); if (!result) { log.warn( - `LokiSnodeAPI::requestSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value`, + `LokiSnodeAPI::requestSnodesForPubkey - lokiRpc on ${targetNode.ip}:${targetNode.port} returned falsish value`, result ); return []; } - const res = result as SnodeResponse; - - if (res.status !== 200) { + if (result.status !== 200) { log.warn('Status is not 200 for get_snodes_for_pubkey'); return []; } try { - const json = JSON.parse(res.body); + const json = JSON.parse(result.body); if (!json.snodes) { // we hit this when snode gives 500s log.warn( - `LokiSnodeAPI::requestSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value for snodes`, + `LokiSnodeAPI::requestSnodesForPubkey - lokiRpc on ${targetNode.ip}:${targetNode.port} returned falsish value for snodes`, result ); return []; @@ -282,19 +243,18 @@ export async function requestSnodesForPubkey(pubKey: string): Promise { + return pRetry( + async () => { + return getSnodePoolFromSnode(node); + }, + { + retries: 3, + factor: 1, + minTimeout: 1000, + } + ); + }) + ); - // Wrong swarm + // we want those at least `requiredSnodesForAgreement` snodes common between all the result + const commonSnodes = _.intersectionWith( + results[0], + results[1], + results[2], + (s1: Snode, s2: Snode) => { + return s1.ip === s2.ip && s1.port === s2.port; + } + ); + // We want the snodes to agree on at least this many snodes + if (commonSnodes.length < requiredSnodesForAgreement) { + throw new Error('inconsistentSnodePools'); + } + return commonSnodes; +} + +/** + * Returns a list of uniq snodes got from the specified targetNode + */ +async function getSnodePoolFromSnode(targetNode: Snode): Promise> { + const params = { + endpoint: 'get_service_nodes', + params: { + active_only: true, + // limit: 256, + fields: { + public_ip: true, + storage_port: true, + pubkey_x25519: true, + pubkey_ed25519: true, + }, + }, + }; + const method = 'oxend_request'; + const result = await snodeRpc(method, params, targetNode); + if (!result || result.status !== 200) { + throw new Error('Invalid result'); + } + + try { + const json = JSON.parse(result.body); + + if (!json || !json.result || !json.result.service_node_states?.length) { + window.log.error( + 'loki_snode_api:::getSnodePoolFromSnode - invalid result from seed', + result.body + ); + return []; + } + + // Filter 0.0.0.0 nodes which haven't submitted uptime proofs + const snodes = json.result.service_node_states + .filter((snode: any) => snode.public_ip !== '0.0.0.0') + .map((snode: any) => ({ + ip: snode.public_ip, + port: snode.storage_port, + pubkey_x25519: snode.pubkey_x25519, + pubkey_ed25519: snode.pubkey_ed25519, + version: '', + })) as Array; + + // we the return list by the snode is already made of uniq snodes + return _.compact(snodes); + } catch (e) { + window.log.error('Invalid json response'); + return []; + } +} + +function checkResponse(response: SnodeResponse): void { + if (response.status === 406) { + throw new window.textsecure.TimestampError('Invalid Timestamp (check your clock)'); + } + + // Wrong/invalid swarm if (response.status === 421) { - log.warn('Wrong swarm, now looking at snodes', json.snodes); - const newSwarm = json.snodes ? json.snodes : []; - throw new textsecure.WrongSwarmError(newSwarm); + let json; + try { + json = JSON.parse(response.body); + } catch (e) { + // could not parse result. Consider that snode as invalid + throw new window.textsecure.InvalidateSwarm(); + } + + // The snode isn't associated with the given public key anymore + window.log.warn('Wrong swarm, now looking at snodes', json.snodes); + if (json.snodes?.length) { + throw new window.textsecure.WrongSwarmError(json.snodes); + } + // remove this node from the swarm of this pubkey + throw new window.textsecure.InvalidateSwarm(); } } @@ -331,7 +397,8 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis const { log, textsecure } = window; let successiveFailures = 0; - while (successiveFailures < MAX_ACCEPTABLE_FAILURES) { + + while (successiveFailures < maxAcceptableFailuresStoreOnNode) { // the higher this is, the longer the user delay is // we don't want to burn through all our retries quickly // we need to give the node a chance to heal @@ -343,17 +410,17 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis const result = await snodeRpc('store', params, targetNode); // do not return true if we get false here... - if (result === false) { + if (!result) { // this means the node we asked for is likely down log.warn( - `loki_message:::storeOnNode - Try #${successiveFailures}/${MAX_ACCEPTABLE_FAILURES} ${targetNode.ip}:${targetNode.port} failed` + `loki_message:::storeOnNode - Try #${successiveFailures}/${maxAcceptableFailuresStoreOnNode} ${targetNode.ip}:${targetNode.port} failed` ); successiveFailures += 1; // eslint-disable-next-line no-continue continue; } - const snodeRes = result as SnodeResponse; + const snodeRes = result; checkResponse(snodeRes); @@ -382,6 +449,16 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis // TODO: Handle working connection but error response const body = await e.response.text(); log.warn('loki_message:::storeOnNode - HTTPError body:', body); + } else if (e instanceof window.textsecure.InvalidateSwarm) { + window.log.warn( + 'Got an `InvalidateSwarm` error, removing this node from this swarm of this pubkey' + ); + const existingSwarm = await getSwarm(params.pubKey); + const updatedSwarm = existingSwarm.filter( + node => node.pubkey_ed25519 !== targetNode.pubkey_ed25519 + ); + + await updateSnodesFor(params.pubKey, updatedSwarm); } successiveFailures += 1; } @@ -394,7 +471,7 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis } export async function retrieveNextMessages( - nodeData: Snode, + targetNode: Snode, lastHash: string, pubkey: string ): Promise> { @@ -404,36 +481,42 @@ export async function retrieveNextMessages( }; // let exceptions bubble up - const result = await snodeRpc('retrieve', params, nodeData); + const result = await snodeRpc('retrieve', params, targetNode); if (!result) { window.log.warn( - `loki_message:::_retrieveNextMessages - lokiRpc could not talk to ${nodeData.ip}:${nodeData.port}` + `loki_message:::_retrieveNextMessages - lokiRpc could not talk to ${targetNode.ip}:${targetNode.port}` ); return []; } - const res = result as SnodeResponse; - // NOTE: we call `checkResponse` to check for "wrong swarm" try { - checkResponse(res); + checkResponse(result); } catch (e) { window.log.warn('loki_message:::retrieveNextMessages - send error:', e.code, e.message); if (e instanceof window.textsecure.WrongSwarmError) { const { newSwarm } = e; await updateSnodesFor(params.pubKey, newSwarm); return []; + } else if (e instanceof window.textsecure.InvalidateSwarm) { + const existingSwarm = await getSwarm(params.pubKey); + const updatedSwarm = existingSwarm.filter( + node => node.pubkey_ed25519 !== targetNode.pubkey_ed25519 + ); + + await updateSnodesFor(params.pubKey, updatedSwarm); + return []; } } - if (res.status !== 200) { + if (result.status !== 200) { window.log('retrieve result is not 200'); return []; } try { - const json = JSON.parse(res.body); + const json = JSON.parse(result.body); return json.messages || []; } catch (e) { window.log.warn('exception while parsing json of nextMessage:', e); @@ -441,5 +524,3 @@ export async function retrieveNextMessages( return []; } } - -const MAX_ACCEPTABLE_FAILURES = 10; diff --git a/ts/session/snode_api/snodePool.ts b/ts/session/snode_api/snodePool.ts index 7216ad4fb..21fd7243c 100644 --- a/ts/session/snode_api/snodePool.ts +++ b/ts/session/snode_api/snodePool.ts @@ -1,14 +1,36 @@ import semver from 'semver'; import _ from 'lodash'; -import { getSnodesFromSeedUrl, requestSnodesForPubkey } from './serviceNodeAPI'; +import { + getSnodePoolFromSnodes, + getSnodesFromSeedUrl, + requestSnodesForPubkey, +} from './serviceNodeAPI'; -import { getSwarmNodesForPubkey, updateSwarmNodesForPubkey } from '../../../ts/data/data'; +import * as Data from '../../../ts/data/data'; export type SnodeEdKey = string; import { allowOnlyOneAtATime } from '../utils/Promise'; +import pRetry from 'p-retry'; -const MIN_NODES = 3; +/** + * If we get less than this snode in a swarm, we fetch new snodes for this pubkey + */ +const minSwarmSnodeCount = 3; + +/** + * If we get less than minSnodePoolCount we consider that we need to fetch the new snode pool from a seed node + * and not from those snodes. + */ +const minSnodePoolCount = 12; + +/** + * 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. + * + * Too many nodes are not shared for this call to be trustworthy + */ +export const requiredSnodesForAgreement = 24; export interface Snode { ip: string; @@ -24,10 +46,13 @@ let randomSnodePool: Array = []; // We only store nodes' identifiers here, const nodesForPubkey: Map> = new Map(); +export type SeedNode = { + url: string; + ip_url: string; +}; + // just get the filtered list -async function tryGetSnodeListFromLokidSeednode( - seedNodes = window.seedNodeList -): Promise> { +async function tryGetSnodeListFromLokidSeednode(seedNodes: Array): Promise> { const { log } = window; if (!seedNodes.length) { @@ -106,7 +131,7 @@ export async function getRandomSnodeAddress(): Promise { if (randomSnodePool.length === 0) { // TODO: ensure that we only call this once at a time // Should not this be saved to the database? - await refreshRandomPool([]); + await refreshRandomPool(); if (randomSnodePool.length === 0) { throw new window.textsecure.SeedNodeError('Invalid seed node response'); @@ -117,51 +142,19 @@ export async function getRandomSnodeAddress(): Promise { return _.sample(randomSnodePool) as Snode; } -function compareSnodes(lhs: any, rhs: any): boolean { - return lhs.pubkey_ed25519 === rhs.pubkey_ed25519; -} - -/** - * Request the version of the snode. - * THIS IS AN INSECURE NODE FETCH and leaks our IP to all snodes but with no other identifying information - * except "that a client started up" or "ran out of random pool snodes" - * and the order of the list is randomized, so a snode can't tell if it just started or not - */ -async function requestVersion(node: any): Promise { - const { log } = window; - - // WARNING: getVersion is doing an insecure node fetch. - // be sure to update getVersion to onion routing if we need this call again. - const result = false; // await getVersion(node); - - if (result === false) { - return; - } - - const version = result as string; - - const foundNodeIdx = randomSnodePool.findIndex((n: any) => compareSnodes(n, node)); - if (foundNodeIdx !== -1) { - randomSnodePool[foundNodeIdx].version = version; - } else { - // maybe already marked bad... - log.debug(`LokiSnodeAPI::_getVersion - can't find ${node.ip}:${node.port} in randomSnodePool`); - } -} - /** * This function force the snode poll to be refreshed from a random seed node again. * This should be called once in a day or so for when the app it kept on. */ export async function forceRefreshRandomSnodePool(): Promise> { - await refreshRandomPool([]); + await refreshRandomPool(); return randomSnodePool; } export async function getRandomSnodePool(): Promise> { if (randomSnodePool.length === 0) { - await refreshRandomPool([]); + await refreshRandomPool(); } return randomSnodePool; } @@ -172,7 +165,7 @@ export function getNodesMinVersion(minVersion: string): Array { } async function getSnodeListFromLokidSeednode( - seedNodes = window.seedNodeList, + seedNodes: Array, retries = 0 ): Promise> { const SEED_NODE_RETRIES = 3; @@ -207,7 +200,11 @@ async function getSnodeListFromLokidSeednode( return snodes; } -async function refreshRandomPoolDetail(seedNodes: Array): Promise { +/** + * Fetch all snodes from a seed nodes if we don't have enough snodes to make the request ourself + * @param seedNodes the seednodes to use to fetch snodes details + */ +async function refreshRandomPoolDetail(seedNodes: Array): Promise { const { log } = window; let snodes = []; @@ -241,21 +238,51 @@ async function refreshRandomPoolDetail(seedNodes: Array): Promise { } } } - -export async function refreshRandomPool(seedNodes?: Array): Promise { +/** + * 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. + */ +export async function refreshRandomPool(): Promise { const { log } = window; - if (!seedNodes || !seedNodes.length) { - if (!window.seedNodeList || !window.seedNodeList.length) { - log.error('LokiSnodeAPI:::refreshRandomPool - seedNodeList has not been loaded yet'); - return; - } - // tslint:disable-next-line:no-parameter-reassignment - seedNodes = window.seedNodeList; + if (!window.seedNodeList || !window.seedNodeList.length) { + log.error('LokiSnodeAPI:::refreshRandomPool - seedNodeList has not been loaded yet'); + return; } + // tslint:disable-next-line:no-parameter-reassignment + const seedNodes = window.seedNodeList; return allowOnlyOneAtATime('refreshRandomPool', async () => { - if (seedNodes) { + // we don't have nodes to fetch the pool from them, so call the seed node instead. + if (randomSnodePool.length < minSnodePoolCount) { + await refreshRandomPoolDetail(seedNodes); + return; + } + try { + // let this request try 3 (2+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. + throw new Error('Not enough common nodes.'); + } + window.log.info('updating snode list with snode pool length:', commonNodes.length); + randomSnodePool = commonNodes; + }, + { + retries: 2, + factor: 1, + minTimeout: 1000, + } + ); + } 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 await refreshRandomPoolDetail(seedNodes); } }); @@ -267,18 +294,20 @@ export async function updateSnodesFor(pubkey: string, snodes: Array): Pro } async function internalUpdateSnodesFor(pubkey: string, edkeys: Array) { + // update our in-memory cache nodesForPubkey.set(pubkey, edkeys); - await updateSwarmNodesForPubkey(pubkey, edkeys); + // write this change to the db + await Data.updateSwarmNodesForPubkey(pubkey, edkeys); } -export async function getSnodesFor(pubkey: string): Promise> { +export async function getSwarm(pubkey: string): Promise> { const maybeNodes = nodesForPubkey.get(pubkey); let nodes: Array; // NOTE: important that maybeNodes is not [] here if (maybeNodes === undefined) { - // First time access, try the database: - nodes = await getSwarmNodesForPubkey(pubkey); + // First time access, no cache yet, let's try the database. + nodes = await Data.getSwarmNodesForPubkey(pubkey); nodesForPubkey.set(pubkey, nodes); } else { nodes = maybeNodes; @@ -287,13 +316,12 @@ export async function getSnodesFor(pubkey: string): Promise> { // See how many are actually still reachable const goodNodes = randomSnodePool.filter((n: Snode) => nodes.indexOf(n.pubkey_ed25519) !== -1); - if (goodNodes.length < MIN_NODES) { + if (goodNodes.length < minSwarmSnodeCount) { // Request new node list from the network const freshNodes = _.shuffle(await requestSnodesForPubkey(pubkey)); const edkeys = freshNodes.map((n: Snode) => n.pubkey_ed25519); - void internalUpdateSnodesFor(pubkey, edkeys); - // TODO: We could probably check that the retuned sndoes are not "unreachable" + await internalUpdateSnodesFor(pubkey, edkeys); return freshNodes; } else { diff --git a/ts/session/snode_api/swarmPolling.ts b/ts/session/snode_api/swarmPolling.ts index 4d406c923..c1bb03259 100644 --- a/ts/session/snode_api/swarmPolling.ts +++ b/ts/session/snode_api/swarmPolling.ts @@ -1,5 +1,5 @@ import { PubKey } from '../types'; -import { getSnodesFor, Snode } from './snodePool'; +import { getSwarm, Snode } from './snodePool'; import { retrieveNextMessages } from './serviceNodeAPI'; import { SignalService } from '../../protobuf'; import * as Receiver from '../../receiver/receiver'; @@ -91,7 +91,7 @@ export class SwarmPolling { // accept both until this is fixed: const pkStr = pubkey.key; - const snodes = await getSnodesFor(pkStr); + const snodes = await getSwarm(pkStr); // Select nodes for which we already have lastHashes const alreadyPolled = snodes.filter((n: Snode) => this.lastHashes[n.pubkey_ed25519]); diff --git a/ts/session/utils/syncUtils.ts b/ts/session/utils/syncUtils.ts index a5d1674be..5936e918c 100644 --- a/ts/session/utils/syncUtils.ts +++ b/ts/session/utils/syncUtils.ts @@ -65,6 +65,11 @@ export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = fal new Promise(resolve => { const allConvos = ConversationController.getInstance().getConversations(); + // if we hang for more than 10sec, force resolve this promise. + setTimeout(() => { + resolve(false); + }, 10000); + void getCurrentConfigurationMessage(allConvos) .then(configMessage => { // this just adds the message to the sending queue. diff --git a/ts/window.d.ts b/ts/window.d.ts index 5577fa796..638b82297 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -55,7 +55,6 @@ declare global { useOnionRequests: boolean; useFileOnionRequests: boolean; useFileOnionRequestsV2: boolean; - onionRequestHops: number; useRequestEncryptionKeyPair: boolean; padOutgoingAttachments: boolean; }; @@ -82,9 +81,6 @@ declare global { versionInfo: any; getStoragePubKey: (key: string) => string; getConversations: () => ConversationCollection; - SnodePool: { - getSnodesFor: (string) => any; - }; profileImages: any; MediaRecorder: any; dataURLToBlobSync: any;