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
pull/1622/head
Audric Ackermann 4 years ago committed by GitHub
parent 442b881438
commit 58abd08e6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -59,6 +59,17 @@
} }
inherit(ReplayableError, EmptySwarmError); 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) { function NotFoundError(message, error) {
this.name = 'NotFoundError'; this.name = 'NotFoundError';
this.message = message; this.message = message;

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

@ -58,7 +58,6 @@ window.lokiFeatureFlags = {
useOnionRequests: true, useOnionRequests: true,
useFileOnionRequests: true, useFileOnionRequests: true,
useFileOnionRequestsV2: true, // more compact encoding of files in response useFileOnionRequestsV2: true, // more compact encoding of files in response
onionRequestHops: 3,
useRequestEncryptionKeyPair: false, useRequestEncryptionKeyPair: false,
padOutgoingAttachments: true, padOutgoingAttachments: true,
}; };
@ -83,8 +82,6 @@ window.isBeforeVersion = (toCheck, baseVersion) => {
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
window.CONSTANTS = new (function() { 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 // Number of seconds to turn on notifications after reconnect/start of app
this.NOTIFICATION_ENABLE_TIMEOUT_SECONDS = 10; this.NOTIFICATION_ENABLE_TIMEOUT_SECONDS = 10;
@ -94,8 +91,6 @@ window.CONSTANTS = new (function() {
// Conforms to naming rules here // Conforms to naming rules here
// https://loki.network/2020/03/25/loki-name-system-the-facts/ // 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.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 = { window.versionInfo = {

@ -3,6 +3,7 @@ import React from 'react';
import { ContactType } from './session/SessionMemberListItem'; import { ContactType } from './session/SessionMemberListItem';
import { ToastUtils } from '../session/utils'; import { ToastUtils } from '../session/utils';
import { createClosedGroup as createClosedGroupV2 } from '../receiver/closedGroups'; import { createClosedGroup as createClosedGroupV2 } from '../receiver/closedGroups';
import { VALIDATION } from '../session/constants';
export class MessageView extends React.Component { export class MessageView extends React.Component {
public render() { public render() {
@ -44,7 +45,7 @@ async function createClosedGroup(
ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooShort')); ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooShort'));
return false; 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')); ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooLong'));
return false; return false;
} }
@ -55,7 +56,7 @@ async function createClosedGroup(
if (groupMembers.length < 1) { if (groupMembers.length < 1) {
ToastUtils.pushToastError('pickClosedGroupMember', window.i18n('pickClosedGroupMember')); ToastUtils.pushToastError('pickClosedGroupMember', window.i18n('pickClosedGroupMember'));
return false; 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')); ToastUtils.pushToastError('closedGroupMaxSize', window.i18n('closedGroupMaxSize'));
return false; return false;
} }

@ -11,6 +11,7 @@ import { ConversationModel, ConversationTypeEnum } from '../../models/conversati
import { getCompleteUrlForV2ConvoId } from '../../interactions/conversation'; import { getCompleteUrlForV2ConvoId } from '../../interactions/conversation';
import _ from 'lodash'; import _ from 'lodash';
import autoBind from 'auto-bind'; import autoBind from 'auto-bind';
import { VALIDATION } from '../../session/constants';
interface Props { interface Props {
contactList: Array<any>; contactList: Array<any>;
chatName: string; chatName: string;
@ -151,7 +152,7 @@ class InviteContactsDialogInner extends React.Component<Props, State> {
// be sure to include current zombies in this count // be sure to include current zombies in this count
if ( if (
newMembers.length + existingMembers.length + existingZombies.length > newMembers.length + existingMembers.length + existingZombies.length >
window.CONSTANTS.CLOSED_GROUP_SIZE_LIMIT VALIDATION.CLOSED_GROUP_SIZE_LIMIT
) { ) {
ToastUtils.pushTooManyMembers(); ToastUtils.pushTooManyMembers();
return; return;

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

@ -204,7 +204,8 @@ export class OpenGroupManagerV2 {
} catch (e) { } catch (e) {
window.log.warn('Failed to join open group v2', e); window.log.warn('Failed to join open group v2', e);
await removeV2OpenGroupRoom(conversationId); await removeV2OpenGroupRoom(conversationId);
throw new Error(window.i18n('connectToServerFail')); // throw new Error(window.i18n('connectToServerFail'));
return undefined;
} }
} }
} }

@ -25,6 +25,11 @@ export const CONVERSATION = {
MAX_ATTACHMENT_FILESIZE_BYTES: 6 * 1000 * 1000, MAX_ATTACHMENT_FILESIZE_BYTES: 6 * 1000 * 1000,
}; };
export const VALIDATION = {
MAX_GROUP_NAME_LENGTH: 64,
CLOSED_GROUP_SIZE_LIMIT: 100,
};
export const UI = { export const UI = {
// Pixels (scroll) from the top of the top of message container // Pixels (scroll) from the top of the top of message container
// at which more messages should be loaded // at which more messages should be loaded

@ -11,7 +11,7 @@ import {
ConversationTypeEnum, ConversationTypeEnum,
} from '../../models/conversation'; } from '../../models/conversation';
import { BlockedNumberController } from '../../util'; import { BlockedNumberController } from '../../util';
import { getSnodesFor } from '../snode_api/snodePool'; import { getSwarm } from '../snode_api/snodePool';
import { PubKey } from '../types'; import { PubKey } from '../types';
import { actions as conversationActions } from '../../state/ducks/conversations'; import { actions as conversationActions } from '../../state/ducks/conversations';
import { getV2OpenGroupRoom, removeV2OpenGroupRoom } from '../../data/opengroups'; import { getV2OpenGroupRoom, removeV2OpenGroupRoom } from '../../data/opengroups';
@ -120,7 +120,7 @@ export class ConversationController {
await Promise.all([ await Promise.all([
conversation.updateProfileAvatar(), conversation.updateProfileAvatar(),
// NOTE: we request snodes updating the cache, but ignore the result // NOTE: we request snodes updating the cache, but ignore the result
void getSnodesFor(id), void getSwarm(id),
]); ]);
} }
}); });

@ -8,6 +8,8 @@ import { allowOnlyOneAtATime } from '../utils/Promise';
export type Snode = SnodePool.Snode; export type Snode = SnodePool.Snode;
const desiredGuardCount = 3;
const minimumGuardCount = 2;
interface SnodePath { interface SnodePath {
path: Array<Snode>; path: Array<Snode>;
bad: boolean; bad: boolean;
@ -15,7 +17,7 @@ interface SnodePath {
export class OnionPaths { export class OnionPaths {
private static instance: OnionPaths | null; private static instance: OnionPaths | null;
private static readonly onionRequestHops = 3;
private onionPaths: Array<SnodePath> = []; private onionPaths: Array<SnodePath> = [];
// This array is meant to store nodes will full info, // 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 goodPaths = this.onionPaths.filter(x => !x.bad);
let attemptNumber = 0; let attemptNumber = 0;
while (goodPaths.length < CONSTANTS.MIN_GUARD_COUNT) { while (goodPaths.length < minimumGuardCount) {
log.error( log.error(
`Must have at least 2 good onion paths, actual: ${goodPaths.length}, attempt #${attemptNumber} fetching more...` `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>> { private async selectGuardNodes(): Promise<Array<Snode>> {
const { CONSTANTS, log } = window; const { log } = window;
// `getRandomSnodePool` is expected to refresh itself on low nodes // `getRandomSnodePool` is expected to refresh itself on low nodes
const nodePool = await SnodePool.getRandomSnodePool(); 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); log.error('Could not select guard nodes. Not enough nodes in the pool: ', nodePool.length);
return []; return [];
} }
@ -191,12 +193,12 @@ export class OnionPaths {
// we only want to repeat if the await fails // we only want to repeat if the await fails
// eslint-disable-next-line-no-await-in-loop // eslint-disable-next-line-no-await-in-loop
while (guardNodes.length < 3) { while (guardNodes.length < 3) {
if (shuffled.length < CONSTANTS.DESIRED_GUARD_COUNT) { if (shuffled.length < desiredGuardCount) {
log.error('Not enought nodes in the pool'); log.error('Not enought nodes in the pool');
break; break;
} }
const candidateNodes = shuffled.splice(0, CONSTANTS.DESIRED_GUARD_COUNT); const candidateNodes = shuffled.splice(0, desiredGuardCount);
// Test all three nodes at once // Test all three nodes at once
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
@ -209,7 +211,7 @@ export class OnionPaths {
guardNodes = _.concat(guardNodes, goodNodes); 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}`); log.error(`COULD NOT get enough guard nodes, only have: ${guardNodes.length}`);
} }
@ -223,7 +225,7 @@ export class OnionPaths {
} }
private async buildNewOnionPathsWorker() { private async buildNewOnionPathsWorker() {
const { CONSTANTS, log } = window; const { log } = window;
log.info('LokiSnodeAPI::buildNewOnionPaths - building new onion paths'); 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 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 // TODO: don't throw away potentially good guard nodes
this.guardNodes = await this.selectGuardNodes(); this.guardNodes = await this.selectGuardNodes();
} }
@ -272,7 +274,7 @@ export class OnionPaths {
const guards = _.shuffle(this.guardNodes); const guards = _.shuffle(this.guardNodes);
// Create path for every guard node: // 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: // Each path needs X (nodesNeededPerPaths) nodes in addition to the guard node:
const maxPath = Math.floor( const maxPath = Math.floor(

@ -1,7 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import { Snode } from '../onions'; import { Snode } from '../onions';
import { SendParams, storeOnNode } from '../snode_api/serviceNodeAPI'; import { SendParams, storeOnNode } from '../snode_api/serviceNodeAPI';
import { getSnodesFor } from '../snode_api/snodePool'; import { getSwarm } from '../snode_api/snodePool';
import { firstTrue } from '../utils/Promise'; import { firstTrue } from '../utils/Promise';
const DEFAULT_CONNECTIONS = 3; const DEFAULT_CONNECTIONS = 3;
@ -46,7 +46,7 @@ export async function sendMessage(
const data64 = window.dcodeIO.ByteBuffer.wrap(data).toString('base64'); const data64 = window.dcodeIO.ByteBuffer.wrap(data).toString('base64');
// Using timestamp as a unique identifier // Using timestamp as a unique identifier
const swarm = await getSnodesFor(pubKey); const swarm = await getSwarm(pubKey);
// send parameters // send parameters
const params = { const params = {
@ -62,7 +62,6 @@ export async function sendMessage(
let snode; let snode;
try { try {
// eslint-disable-next-line more/no-then
snode = await firstTrue(promises); snode = await firstTrue(promises);
} catch (e) { } catch (e) {
const snodeStr = snode ? `${snode.ip}:${snode.port}` : 'null'; const snodeStr = snode ? `${snode.ip}:${snode.port}` : 'null';

@ -14,7 +14,7 @@ async function lokiFetch(
url: string, url: string,
options: FetchOptions, options: FetchOptions,
targetNode?: Snode targetNode?: Snode
): Promise<boolean | SnodeResponse> { ): Promise<undefined | SnodeResponse> {
const timeout = 10000; const timeout = 10000;
const method = options.method || 'GET'; const method = options.method || 'GET';
@ -28,7 +28,11 @@ async function lokiFetch(
// Absence of targetNode indicates that we want a direct connection // Absence of targetNode indicates that we want a direct connection
// (e.g. to connect to a seed node for the first time) // (e.g. to connect to a seed node for the first time)
if (window.lokiFeatureFlags.useOnionRequests && targetNode) { 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:\/\//)) { if (url.match(/https:\/\//)) {
@ -62,7 +66,7 @@ export async function snodeRpc(
method: string, method: string,
params: any, params: any,
targetNode: Snode targetNode: Snode
): Promise<boolean | SnodeResponse> { ): Promise<undefined | SnodeResponse> {
const url = `https://${targetNode.ip}:${targetNode.port}/storage_rpc/v1`; const url = `https://${targetNode.ip}:${targetNode.port}/storage_rpc/v1`;
// TODO: The jsonrpc and body field will be ignored on storage server // TODO: The jsonrpc and body field will be ignored on storage server

@ -1,9 +1,8 @@
import { default as insecureNodeFetch } from 'node-fetch'; import { default as insecureNodeFetch, Response } from 'node-fetch';
import https from 'https'; import https from 'https';
import { Snode } from './snodePool'; import { Snode } from './snodePool';
import ByteBuffer from 'bytebuffer'; import ByteBuffer from 'bytebuffer';
import { StringUtils } from '../utils';
import { OnionPaths } from '../onions'; import { OnionPaths } from '../onions';
import { fromBase64ToArrayBuffer, toHex } from '../utils/String'; 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. // May return false BAD_PATH, indicating that we should try a new path.
const processOnionResponse = async ( const processOnionResponse = async (
reqIdx: number, reqIdx: number,
response: any, response: Response,
symmetricKey: ArrayBuffer, symmetricKey: ArrayBuffer,
debug: boolean, debug: boolean,
abortSignal?: AbortSignal abortSignal?: AbortSignal
@ -231,6 +230,7 @@ const processOnionResponse = async (
if (response.status !== 200) { if (response.status !== 200) {
const rsp = await response.text(); const rsp = await response.text();
log.warn( log.warn(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}: ${rsp}` `(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}: ${rsp}`
); );
@ -241,7 +241,7 @@ const processOnionResponse = async (
return RequestError.BAD_PATH; return RequestError.BAD_PATH;
} }
let ciphertext = (await response.text()) as string; let ciphertext = await response.text();
if (!ciphertext) { if (!ciphertext) {
log.warn( log.warn(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext` `(${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(', '); return pathObjArr.map(node => `${node.ip}:${node.port}`).join(', ');
} }
export async function lokiOnionFetch( export async function lokiOnionFetch(body: any, targetNode: Snode): Promise<SnodeResponse | false> {
body: any,
targetNode: Snode
): Promise<SnodeResponse | boolean> {
const { log } = window; const { log } = window;
// Loop until the result is not BAD_PATH // Loop until the result is not BAD_PATH

@ -15,59 +15,22 @@ import { sendOnionRequestLsrpcDest, snodeHttpsAgent, SnodeResponse } from './oni
export { sendOnionRequestLsrpcDest }; export { sendOnionRequestLsrpcDest };
import { getRandomSnodeAddress, markNodeUnreachable, Snode, updateSnodesFor } from './snodePool'; import {
getRandomSnodeAddress,
getRandomSnodePool,
getSwarm,
markNodeUnreachable,
requiredSnodesForAgreement,
Snode,
updateSnodesFor,
} from './snodePool';
import { Constants } from '..'; import { Constants } from '..';
import { sleepFor } from '../utils/Promise'; import { sleepFor } from '../utils/Promise';
import { sha256 } from '../crypto'; import { sha256 } from '../crypto';
import pRetry from 'p-retry';
import _ from 'lodash';
/** const maxAcceptableFailuresStoreOnNode = 10;
* 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 getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => { const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => {
let filePrefix = ''; let filePrefix = '';
@ -235,39 +198,37 @@ export type SendParams = {
export async function requestSnodesForPubkey(pubKey: string): Promise<Array<Snode>> { export async function requestSnodesForPubkey(pubKey: string): Promise<Array<Snode>> {
const { log } = window; const { log } = window;
let snode; let targetNode;
try { try {
snode = await getRandomSnodeAddress(); targetNode = await getRandomSnodeAddress();
const result = await snodeRpc( const result = await snodeRpc(
'get_snodes_for_pubkey', 'get_snodes_for_pubkey',
{ {
pubKey, pubKey,
}, },
snode targetNode
); );
if (!result) { if (!result) {
log.warn( 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 result
); );
return []; return [];
} }
const res = result as SnodeResponse; if (result.status !== 200) {
if (res.status !== 200) {
log.warn('Status is not 200 for get_snodes_for_pubkey'); log.warn('Status is not 200 for get_snodes_for_pubkey');
return []; return [];
} }
try { try {
const json = JSON.parse(res.body); const json = JSON.parse(result.body);
if (!json.snodes) { if (!json.snodes) {
// we hit this when snode gives 500s // we hit this when snode gives 500s
log.warn( 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 result
); );
return []; return [];
@ -282,19 +243,18 @@ export async function requestSnodesForPubkey(pubKey: string): Promise<Array<Snod
} catch (e) { } catch (e) {
log.error('LokiSnodeAPI::requestSnodesForPubkey - error', e.code, e.message); log.error('LokiSnodeAPI::requestSnodesForPubkey - error', e.code, e.message);
if (snode) { if (targetNode) {
markNodeUnreachable(snode); markNodeUnreachable(targetNode);
} }
return []; return [];
} }
} }
export async function requestLnsMapping(node: Snode, nameHash: any) { export async function requestLnsMapping(targetNode: Snode, nameHash: any) {
const { log } = window; const { log } = window;
log.debug('[lns] lns requests to {}:{}', node.ip, node.port); log.debug('[lns] lns requests to {}:{}', targetNode.ip, targetNode);
try { try {
// TODO: Check response status // TODO: Check response status
return snodeRpc( return snodeRpc(
@ -302,28 +262,134 @@ export async function requestLnsMapping(node: Snode, nameHash: any) {
{ {
name_hash: nameHash, name_hash: nameHash,
}, },
node targetNode
); );
} catch (e) { } 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; return false;
} }
} }
function checkResponse(response: SnodeResponse): void { /**
const { log, textsecure } = window; * 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;
}
if (response.status === 406) { // Note intersectionWith only works with 3 at most array to find the common snodes.
throw new textsecure.TimestampError('Invalid Timestamp (check your clock)'); 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,
}
);
})
);
// 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;
}
const json = JSON.parse(response.body); /**
* 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 [];
}
// Wrong swarm // 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) { if (response.status === 421) {
log.warn('Wrong swarm, now looking at snodes', json.snodes); let json;
const newSwarm = json.snodes ? json.snodes : []; try {
throw new textsecure.WrongSwarmError(newSwarm); 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; const { log, textsecure } = window;
let successiveFailures = 0; let successiveFailures = 0;
while (successiveFailures < MAX_ACCEPTABLE_FAILURES) {
while (successiveFailures < maxAcceptableFailuresStoreOnNode) {
// the higher this is, the longer the user delay is // the higher this is, the longer the user delay is
// we don't want to burn through all our retries quickly // we don't want to burn through all our retries quickly
// we need to give the node a chance to heal // 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); const result = await snodeRpc('store', params, targetNode);
// do not return true if we get false here... // do not return true if we get false here...
if (result === false) { if (!result) {
// this means the node we asked for is likely down // this means the node we asked for is likely down
log.warn( 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; successiveFailures += 1;
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
continue; continue;
} }
const snodeRes = result as SnodeResponse; const snodeRes = result;
checkResponse(snodeRes); checkResponse(snodeRes);
@ -382,6 +449,16 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis
// TODO: Handle working connection but error response // TODO: Handle working connection but error response
const body = await e.response.text(); const body = await e.response.text();
log.warn('loki_message:::storeOnNode - HTTPError body:', body); 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; successiveFailures += 1;
} }
@ -394,7 +471,7 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis
} }
export async function retrieveNextMessages( export async function retrieveNextMessages(
nodeData: Snode, targetNode: Snode,
lastHash: string, lastHash: string,
pubkey: string pubkey: string
): Promise<Array<any>> { ): Promise<Array<any>> {
@ -404,36 +481,42 @@ export async function retrieveNextMessages(
}; };
// let exceptions bubble up // let exceptions bubble up
const result = await snodeRpc('retrieve', params, nodeData); const result = await snodeRpc('retrieve', params, targetNode);
if (!result) { if (!result) {
window.log.warn( 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 []; return [];
} }
const res = result as SnodeResponse;
// NOTE: we call `checkResponse` to check for "wrong swarm" // NOTE: we call `checkResponse` to check for "wrong swarm"
try { try {
checkResponse(res); checkResponse(result);
} catch (e) { } catch (e) {
window.log.warn('loki_message:::retrieveNextMessages - send error:', e.code, e.message); window.log.warn('loki_message:::retrieveNextMessages - send error:', e.code, e.message);
if (e instanceof window.textsecure.WrongSwarmError) { if (e instanceof window.textsecure.WrongSwarmError) {
const { newSwarm } = e; const { newSwarm } = e;
await updateSnodesFor(params.pubKey, newSwarm); await updateSnodesFor(params.pubKey, newSwarm);
return []; 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'); window.log('retrieve result is not 200');
return []; return [];
} }
try { try {
const json = JSON.parse(res.body); const json = JSON.parse(result.body);
return json.messages || []; return json.messages || [];
} catch (e) { } catch (e) {
window.log.warn('exception while parsing json of nextMessage:', e); window.log.warn('exception while parsing json of nextMessage:', e);
@ -441,5 +524,3 @@ export async function retrieveNextMessages(
return []; return [];
} }
} }
const MAX_ACCEPTABLE_FAILURES = 10;

@ -1,14 +1,36 @@
import semver from 'semver'; import semver from 'semver';
import _ from 'lodash'; 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; export type SnodeEdKey = string;
import { allowOnlyOneAtATime } from '../utils/Promise'; 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 { export interface Snode {
ip: string; ip: string;
@ -24,10 +46,13 @@ let randomSnodePool: Array<Snode> = [];
// We only store nodes' identifiers here, // We only store nodes' identifiers here,
const nodesForPubkey: Map<string, Array<SnodeEdKey>> = new Map(); const nodesForPubkey: Map<string, Array<SnodeEdKey>> = new Map();
export type SeedNode = {
url: string;
ip_url: string;
};
// just get the filtered list // just get the filtered list
async function tryGetSnodeListFromLokidSeednode( async function tryGetSnodeListFromLokidSeednode(seedNodes: Array<SeedNode>): Promise<Array<Snode>> {
seedNodes = window.seedNodeList
): Promise<Array<Snode>> {
const { log } = window; const { log } = window;
if (!seedNodes.length) { if (!seedNodes.length) {
@ -106,7 +131,7 @@ export async function getRandomSnodeAddress(): Promise<Snode> {
if (randomSnodePool.length === 0) { if (randomSnodePool.length === 0) {
// TODO: ensure that we only call this once at a time // TODO: ensure that we only call this once at a time
// Should not this be saved to the database? // Should not this be saved to the database?
await refreshRandomPool([]); await refreshRandomPool();
if (randomSnodePool.length === 0) { if (randomSnodePool.length === 0) {
throw new window.textsecure.SeedNodeError('Invalid seed node response'); throw new window.textsecure.SeedNodeError('Invalid seed node response');
@ -117,51 +142,19 @@ export async function getRandomSnodeAddress(): Promise<Snode> {
return _.sample(randomSnodePool) as 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 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. * This should be called once in a day or so for when the app it kept on.
*/ */
export async function forceRefreshRandomSnodePool(): Promise<Array<Snode>> { export async function forceRefreshRandomSnodePool(): Promise<Array<Snode>> {
await refreshRandomPool([]); await refreshRandomPool();
return randomSnodePool; return randomSnodePool;
} }
export async function getRandomSnodePool(): Promise<Array<Snode>> { export async function getRandomSnodePool(): Promise<Array<Snode>> {
if (randomSnodePool.length === 0) { if (randomSnodePool.length === 0) {
await refreshRandomPool([]); await refreshRandomPool();
} }
return randomSnodePool; return randomSnodePool;
} }
@ -172,7 +165,7 @@ export function getNodesMinVersion(minVersion: string): Array<Snode> {
} }
async function getSnodeListFromLokidSeednode( async function getSnodeListFromLokidSeednode(
seedNodes = window.seedNodeList, seedNodes: Array<SeedNode>,
retries = 0 retries = 0
): Promise<Array<Snode>> { ): Promise<Array<Snode>> {
const SEED_NODE_RETRIES = 3; const SEED_NODE_RETRIES = 3;
@ -207,7 +200,11 @@ async function getSnodeListFromLokidSeednode(
return snodes; 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; const { log } = window;
let snodes = []; 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; const { log } = window;
if (!seedNodes || !seedNodes.length) { if (!window.seedNodeList || !window.seedNodeList.length) {
if (!window.seedNodeList || !window.seedNodeList.length) { log.error('LokiSnodeAPI:::refreshRandomPool - seedNodeList has not been loaded yet');
log.error('LokiSnodeAPI:::refreshRandomPool - seedNodeList has not been loaded yet'); return;
return;
}
// tslint:disable-next-line:no-parameter-reassignment
seedNodes = window.seedNodeList;
} }
// tslint:disable-next-line:no-parameter-reassignment
const seedNodes = window.seedNodeList;
return allowOnlyOneAtATime('refreshRandomPool', async () => { 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); 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>) { async function internalUpdateSnodesFor(pubkey: string, edkeys: Array<string>) {
// update our in-memory cache
nodesForPubkey.set(pubkey, edkeys); 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); const maybeNodes = nodesForPubkey.get(pubkey);
let nodes: Array<string>; let nodes: Array<string>;
// NOTE: important that maybeNodes is not [] here // NOTE: important that maybeNodes is not [] here
if (maybeNodes === undefined) { if (maybeNodes === undefined) {
// First time access, try the database: // First time access, no cache yet, let's try the database.
nodes = await getSwarmNodesForPubkey(pubkey); nodes = await Data.getSwarmNodesForPubkey(pubkey);
nodesForPubkey.set(pubkey, nodes); nodesForPubkey.set(pubkey, nodes);
} else { } else {
nodes = maybeNodes; nodes = maybeNodes;
@ -287,13 +316,12 @@ export async function getSnodesFor(pubkey: string): Promise<Array<Snode>> {
// See how many are actually still reachable // See how many are actually still reachable
const goodNodes = randomSnodePool.filter((n: Snode) => nodes.indexOf(n.pubkey_ed25519) !== -1); 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 // Request new node list from the network
const freshNodes = _.shuffle(await requestSnodesForPubkey(pubkey)); const freshNodes = _.shuffle(await requestSnodesForPubkey(pubkey));
const edkeys = freshNodes.map((n: Snode) => n.pubkey_ed25519); const edkeys = freshNodes.map((n: Snode) => n.pubkey_ed25519);
void internalUpdateSnodesFor(pubkey, edkeys); await internalUpdateSnodesFor(pubkey, edkeys);
// TODO: We could probably check that the retuned sndoes are not "unreachable"
return freshNodes; return freshNodes;
} else { } else {

@ -1,5 +1,5 @@
import { PubKey } from '../types'; import { PubKey } from '../types';
import { getSnodesFor, Snode } from './snodePool'; import { getSwarm, Snode } from './snodePool';
import { retrieveNextMessages } from './serviceNodeAPI'; import { retrieveNextMessages } from './serviceNodeAPI';
import { SignalService } from '../../protobuf'; import { SignalService } from '../../protobuf';
import * as Receiver from '../../receiver/receiver'; import * as Receiver from '../../receiver/receiver';
@ -91,7 +91,7 @@ export class SwarmPolling {
// accept both until this is fixed: // accept both until this is fixed:
const pkStr = pubkey.key; const pkStr = pubkey.key;
const snodes = await getSnodesFor(pkStr); const snodes = await getSwarm(pkStr);
// Select nodes for which we already have lastHashes // Select nodes for which we already have lastHashes
const alreadyPolled = snodes.filter((n: Snode) => this.lastHashes[n.pubkey_ed25519]); const alreadyPolled = snodes.filter((n: Snode) => this.lastHashes[n.pubkey_ed25519]);

@ -65,6 +65,11 @@ export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = fal
new Promise(resolve => { new Promise(resolve => {
const allConvos = ConversationController.getInstance().getConversations(); const allConvos = ConversationController.getInstance().getConversations();
// if we hang for more than 10sec, force resolve this promise.
setTimeout(() => {
resolve(false);
}, 10000);
void getCurrentConfigurationMessage(allConvos) void getCurrentConfigurationMessage(allConvos)
.then(configMessage => { .then(configMessage => {
// this just adds the message to the sending queue. // this just adds the message to the sending queue.

4
ts/window.d.ts vendored

@ -55,7 +55,6 @@ declare global {
useOnionRequests: boolean; useOnionRequests: boolean;
useFileOnionRequests: boolean; useFileOnionRequests: boolean;
useFileOnionRequestsV2: boolean; useFileOnionRequestsV2: boolean;
onionRequestHops: number;
useRequestEncryptionKeyPair: boolean; useRequestEncryptionKeyPair: boolean;
padOutgoingAttachments: boolean; padOutgoingAttachments: boolean;
}; };
@ -82,9 +81,6 @@ declare global {
versionInfo: any; versionInfo: any;
getStoragePubKey: (key: string) => string; getStoragePubKey: (key: string) => string;
getConversations: () => ConversationCollection; getConversations: () => ConversationCollection;
SnodePool: {
getSnodesFor: (string) => any;
};
profileImages: any; profileImages: any;
MediaRecorder: any; MediaRecorder: any;
dataURLToBlobSync: any; dataURLToBlobSync: any;

Loading…
Cancel
Save