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
This commit is contained in:
Audric Ackermann 2021-05-12 10:34:53 +10:00 committed by GitHub
parent 442b881438
commit 58abd08e6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 318 additions and 191 deletions

View File

@ -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;

View File

@ -7,6 +7,7 @@ export interface LibTextsecure {
SendMessageNetworkError: any;
ReplayableError: any;
EmptySwarmError: any;
InvalidateSwarm: any;
SeedNodeError: any;
HTTPError: any;
NotFoundError: any;

View File

@ -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 = {

View File

@ -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;
}

View File

@ -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<any>;
chatName: string;
@ -151,7 +152,7 @@ class InviteContactsDialogInner extends React.Component<Props, State> {
// 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;

View File

@ -252,7 +252,7 @@ class SessionRightPanel extends React.Component<Props, State> {
const showUpdateGroupNameButton = isAdmin && !commonNoShow;
const showAddRemoveModeratorsButton = isAdmin && !commonNoShow && isPublic;
const showUpdateGroupMembersButton = !isPublic && !commonNoShow;
const showUpdateGroupMembersButton = !isPublic && isGroup && !commonNoShow;
return (
<div className="group-settings">

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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),
]);
}
});

View File

@ -8,6 +8,8 @@ import { allowOnlyOneAtATime } from '../utils/Promise';
export type Snode = SnodePool.Snode;
const desiredGuardCount = 3;
const minimumGuardCount = 2;
interface SnodePath {
path: Array<Snode>;
bad: boolean;
@ -15,7 +17,7 @@ interface SnodePath {
export class OnionPaths {
private static instance: OnionPaths | null;
private static readonly onionRequestHops = 3;
private onionPaths: Array<SnodePath> = [];
// 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<Array<Snode>> {
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(

View File

@ -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';

View File

@ -14,7 +14,7 @@ async function lokiFetch(
url: string,
options: FetchOptions,
targetNode?: Snode
): Promise<boolean | SnodeResponse> {
): Promise<undefined | SnodeResponse> {
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<boolean | SnodeResponse> {
): Promise<undefined | SnodeResponse> {
const url = `https://${targetNode.ip}:${targetNode.port}/storage_rpc/v1`;
// TODO: The jsonrpc and body field will be ignored on storage server

View File

@ -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<any>): string {
return pathObjArr.map(node => `${node.ip}:${node.port}`).join(', ');
}
export async function lokiOnionFetch(
body: any,
targetNode: Snode
): Promise<SnodeResponse | boolean> {
export async function lokiOnionFetch(body: any, targetNode: Snode): Promise<SnodeResponse | false> {
const { log } = window;
// Loop until the result is not BAD_PATH

View File

@ -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<string | boolean> {
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<Array<Snode>> {
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<Array<Snod
} catch (e) {
log.error('LokiSnodeAPI::requestSnodesForPubkey - error', e.code, e.message);
if (snode) {
markNodeUnreachable(snode);
if (targetNode) {
markNodeUnreachable(targetNode);
}
return [];
}
}
export async function requestLnsMapping(node: Snode, nameHash: any) {
export async function requestLnsMapping(targetNode: Snode, nameHash: any) {
const { log } = window;
log.debug('[lns] lns requests to {}:{}', node.ip, node.port);
log.debug('[lns] lns requests to {}:{}', targetNode.ip, targetNode);
try {
// TODO: Check response status
return snodeRpc(
@ -302,28 +262,134 @@ export async function requestLnsMapping(node: Snode, nameHash: any) {
{
name_hash: nameHash,
},
node
targetNode
);
} catch (e) {
log.warn('exception caught making lns requests to a node', node, e);
log.warn('exception caught making lns requests to a node', targetNode, e);
return false;
}
}
function checkResponse(response: SnodeResponse): void {
const { log, textsecure } = window;
if (response.status === 406) {
throw new textsecure.TimestampError('Invalid Timestamp (check your clock)');
/**
* 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.
* 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 json = JSON.parse(response.body);
// 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 => {
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<Array<Snode>> {
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<Snode>;
// 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<Array<any>> {
@ -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;

View File

@ -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<Snode> = [];
// We only store nodes' identifiers here,
const nodesForPubkey: Map<string, Array<SnodeEdKey>> = new Map();
export type SeedNode = {
url: string;
ip_url: string;
};
// just get the filtered list
async function tryGetSnodeListFromLokidSeednode(
seedNodes = window.seedNodeList
): Promise<Array<Snode>> {
async function tryGetSnodeListFromLokidSeednode(seedNodes: Array<SeedNode>): Promise<Array<Snode>> {
const { log } = window;
if (!seedNodes.length) {
@ -106,7 +131,7 @@ export async function getRandomSnodeAddress(): Promise<Snode> {
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<Snode> {
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<void> {
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<Array<Snode>> {
await refreshRandomPool([]);
await refreshRandomPool();
return randomSnodePool;
}
export async function getRandomSnodePool(): Promise<Array<Snode>> {
if (randomSnodePool.length === 0) {
await refreshRandomPool([]);
await refreshRandomPool();
}
return randomSnodePool;
}
@ -172,7 +165,7 @@ export function getNodesMinVersion(minVersion: string): Array<Snode> {
}
async function getSnodeListFromLokidSeednode(
seedNodes = window.seedNodeList,
seedNodes: Array<SeedNode>,
retries = 0
): Promise<Array<Snode>> {
const SEED_NODE_RETRIES = 3;
@ -207,7 +200,11 @@ async function getSnodeListFromLokidSeednode(
return snodes;
}
async function refreshRandomPoolDetail(seedNodes: Array<any>): Promise<void> {
/**
* 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<SeedNode>): Promise<void> {
const { log } = window;
let snodes = [];
@ -241,21 +238,51 @@ async function refreshRandomPoolDetail(seedNodes: Array<any>): Promise<void> {
}
}
}
export async function refreshRandomPool(seedNodes?: Array<any>): Promise<void> {
/**
* 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<void> {
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<Snode>): Pro
}
async function internalUpdateSnodesFor(pubkey: string, edkeys: Array<string>) {
// 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<Array<Snode>> {
export async function getSwarm(pubkey: string): Promise<Array<Snode>> {
const maybeNodes = nodesForPubkey.get(pubkey);
let nodes: Array<string>;
// 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<Array<Snode>> {
// 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 {

View File

@ -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]);

View File

@ -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.

4
ts/window.d.ts vendored
View File

@ -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;