You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import _ from 'lodash';
|
|
|
|
import { getSnodePoolFromSnodes, getSnodesFromSeedUrl, requestSnodesForPubkey } from './SNodeAPI';
|
|
|
|
import * as Data from '../../../ts/data/data';
|
|
|
|
import { allowOnlyOneAtATime } from '../utils/Promise';
|
|
import pRetry from 'p-retry';
|
|
import { ed25519Str } from '../onions/onionPath';
|
|
import { OnionPaths } from '../onions';
|
|
import { Onions } from '.';
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export 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;
|
|
|
|
// This should be renamed to `allNodes` or something
|
|
export let randomSnodePool: Array<Data.Snode> = [];
|
|
|
|
// We only store nodes' identifiers here,
|
|
const swarmCache: Map<string, Array<string>> = new Map();
|
|
|
|
export type SeedNode = {
|
|
url: string;
|
|
};
|
|
|
|
// just get the filtered list
|
|
async function tryGetSnodeListFromLokidSeednode(
|
|
seedNodes: Array<SeedNode>
|
|
): Promise<Array<Data.Snode>> {
|
|
window?.log?.info('tryGetSnodeListFromLokidSeednode starting...');
|
|
|
|
if (!seedNodes.length) {
|
|
window?.log?.info('loki_snode_api::tryGetSnodeListFromLokidSeednode - seedNodes are empty');
|
|
return [];
|
|
}
|
|
|
|
const seedNode = _.sample(seedNodes);
|
|
if (!seedNode) {
|
|
window?.log?.warn(
|
|
'loki_snode_api::tryGetSnodeListFromLokidSeednode - Could not select random snodes from',
|
|
seedNodes
|
|
);
|
|
return [];
|
|
}
|
|
let snodes = [];
|
|
try {
|
|
const tryUrl = new URL(seedNode.url);
|
|
|
|
snodes = await getSnodesFromSeedUrl(tryUrl);
|
|
// throw before clearing the lock, so the retries can kick in
|
|
if (snodes.length === 0) {
|
|
window?.log?.warn(
|
|
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.url} did not return any snodes`
|
|
);
|
|
// does this error message need to be exactly this?
|
|
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
|
|
}
|
|
|
|
return snodes;
|
|
} catch (e) {
|
|
window?.log?.warn(
|
|
'LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - error',
|
|
e.code,
|
|
e.message,
|
|
'on',
|
|
seedNode
|
|
);
|
|
if (snodes.length === 0) {
|
|
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Drop a snode from the snode pool. This does not update the swarm containing this snode.
|
|
* Use `dropSnodeFromSwarmIfNeeded` for that
|
|
* @param snodeEd25519 the snode ed25519 to drop from the snode pool
|
|
*/
|
|
export async function dropSnodeFromSnodePool(snodeEd25519: string) {
|
|
const exists = _.some(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519);
|
|
if (exists) {
|
|
_.remove(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519);
|
|
await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool));
|
|
|
|
window?.log?.warn(
|
|
`Marking ${ed25519Str(snodeEd25519)} as unreachable, ${
|
|
randomSnodePool.length
|
|
} snodes remaining in randomPool`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param excluding can be used to exclude some nodes from the random list. Useful to rebuild a path excluding existing node already in a path
|
|
*/
|
|
export async function getRandomSnode(excludingEd25519Snode?: Array<string>): Promise<Data.Snode> {
|
|
// resolve random snode
|
|
if (randomSnodePool.length === 0) {
|
|
// Should not this be saved to the database?
|
|
await refreshRandomPool();
|
|
|
|
if (randomSnodePool.length === 0) {
|
|
throw new window.textsecure.SeedNodeError('Invalid seed node response');
|
|
}
|
|
}
|
|
// We know the pool can't be empty at this point
|
|
if (!excludingEd25519Snode) {
|
|
return _.sample(randomSnodePool) as Data.Snode;
|
|
}
|
|
|
|
// we have to double check even after removing the nodes to exclude we still have some nodes in the list
|
|
const snodePoolExcluding = randomSnodePool.filter(
|
|
e => !excludingEd25519Snode.includes(e.pubkey_ed25519)
|
|
);
|
|
if (!snodePoolExcluding || !snodePoolExcluding.length) {
|
|
if (window?.textsecure) {
|
|
throw new window.textsecure.SeedNodeError(
|
|
'Not enough snodes with excluding length',
|
|
excludingEd25519Snode.length
|
|
);
|
|
}
|
|
// used for tests
|
|
throw new Error('SeedNodeError');
|
|
}
|
|
return _.sample(snodePoolExcluding) as Data.Snode;
|
|
}
|
|
|
|
/**
|
|
* 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<Data.Snode>> {
|
|
await refreshRandomPool(true);
|
|
|
|
return randomSnodePool;
|
|
}
|
|
|
|
export async function getRandomSnodePool(): Promise<Array<Data.Snode>> {
|
|
if (randomSnodePool.length === 0) {
|
|
await refreshRandomPool();
|
|
}
|
|
return randomSnodePool;
|
|
}
|
|
|
|
async function getSnodeListFromLokidSeednode(
|
|
seedNodes: Array<SeedNode>,
|
|
retries = 0
|
|
): Promise<Array<Data.Snode>> {
|
|
const SEED_NODE_RETRIES = 3;
|
|
window?.log?.info('getSnodeListFromLokidSeednode starting...');
|
|
if (!seedNodes.length) {
|
|
window?.log?.info('loki_snode_api::getSnodeListFromLokidSeednode - seedNodes are empty');
|
|
return [];
|
|
}
|
|
let snodes: Array<Data.Snode> = [];
|
|
try {
|
|
snodes = await tryGetSnodeListFromLokidSeednode(seedNodes);
|
|
} catch (e) {
|
|
window?.log?.warn('loki_snode_api::getSnodeListFromLokidSeednode - error', e.code, e.message);
|
|
// handle retries in case of temporary hiccups
|
|
if (retries < SEED_NODE_RETRIES) {
|
|
setTimeout(async () => {
|
|
window?.log?.info(
|
|
'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #',
|
|
retries,
|
|
'seed nodes total',
|
|
seedNodes.length
|
|
);
|
|
try {
|
|
await getSnodeListFromLokidSeednode(seedNodes, retries + 1);
|
|
} catch (e) {
|
|
window?.log?.warn('getSnodeListFromLokidSeednode failed retr y #', retries, e);
|
|
}
|
|
}, retries * retries * 5000);
|
|
} else {
|
|
window?.log?.error('loki_snode_api::getSnodeListFromLokidSeednode - failing');
|
|
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
|
|
}
|
|
}
|
|
return snodes;
|
|
}
|
|
|
|
/**
|
|
* Fetch all snodes from a seed nodes if we don't have enough snodes to make the request ourself.
|
|
* Exported only for tests. This is not to be used by the app directly
|
|
* @param seedNodes the seednodes to use to fetch snodes details
|
|
*/
|
|
export async function refreshRandomPoolDetail(
|
|
seedNodes: Array<SeedNode>
|
|
): Promise<Array<Data.Snode>> {
|
|
let snodes = [];
|
|
try {
|
|
window?.log?.info(`refreshRandomPoolDetail with seedNodes.length ${seedNodes.length}`);
|
|
|
|
snodes = await getSnodeListFromLokidSeednode(seedNodes);
|
|
// make sure order of the list is random, so we get version in a non-deterministic way
|
|
snodes = _.shuffle(snodes);
|
|
// commit changes to be live
|
|
// we'll update the version (in case they upgrade) every cycle
|
|
const fetchSnodePool = snodes.map((snode: any) => ({
|
|
ip: snode.public_ip,
|
|
port: snode.storage_port,
|
|
pubkey_x25519: snode.pubkey_x25519,
|
|
pubkey_ed25519: snode.pubkey_ed25519,
|
|
version: '',
|
|
}));
|
|
window?.log?.info(
|
|
'LokiSnodeAPI::refreshRandomPool - Refreshed random snode pool with',
|
|
snodes.length,
|
|
'snodes'
|
|
);
|
|
return fetchSnodePool;
|
|
} catch (e) {
|
|
window?.log?.warn('LokiSnodeAPI::refreshRandomPool - error', e.code, e.message);
|
|
/*
|
|
log.error(
|
|
'LokiSnodeAPI:::refreshRandomPoolPromise - Giving up trying to contact seed node'
|
|
);
|
|
*/
|
|
if (snodes.length === 0) {
|
|
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
/**
|
|
* 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(forceRefresh = false): Promise<void> {
|
|
const seedNodes = window.getSeedNodeList();
|
|
|
|
if (!seedNodes || !seedNodes.length) {
|
|
window?.log?.error(
|
|
'LokiSnodeAPI:::refreshRandomPool - getSeedNodeList has not been loaded yet'
|
|
);
|
|
|
|
return;
|
|
}
|
|
window?.log?.info("right before allowOnlyOneAtATime 'refreshRandomPool'");
|
|
|
|
return allowOnlyOneAtATime('refreshRandomPool', async () => {
|
|
window?.log?.info("running allowOnlyOneAtATime 'refreshRandomPool'");
|
|
|
|
// if we have forceRefresh set, we want to request snodes from snodes or from the seed server.
|
|
if (randomSnodePool.length === 0 && !forceRefresh) {
|
|
const fetchedFromDb = await Data.getSnodePoolFromDb();
|
|
// write to memory only if it is valid.
|
|
// if the size is not enough. we will contact a seed node.
|
|
if (fetchedFromDb?.length) {
|
|
window?.log?.info(`refreshRandomPool: fetched from db ${fetchedFromDb.length} snodes.`);
|
|
randomSnodePool = fetchedFromDb;
|
|
if (randomSnodePool.length <= minSnodePoolCount) {
|
|
window?.log?.warn('refreshRandomPool: not enough snodes in db, going to fetch from seed');
|
|
} else {
|
|
return;
|
|
}
|
|
} else {
|
|
window?.log?.warn('refreshRandomPool: did not find snodes in db.');
|
|
}
|
|
}
|
|
|
|
// we don't have nodes to fetch the pool from them, so call the seed node instead.
|
|
if (randomSnodePool.length <= minSnodePoolCount) {
|
|
window?.log?.info(
|
|
`refreshRandomPool: NOT enough snodes to fetch from them ${randomSnodePool.length} <= ${minSnodePoolCount}, so falling back to seedNodes ${seedNodes?.length}`
|
|
);
|
|
|
|
randomSnodePool = await exports.refreshRandomPoolDetail(seedNodes);
|
|
await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool));
|
|
return;
|
|
}
|
|
try {
|
|
window?.log?.info(
|
|
`refreshRandomPool: enough snodes to fetch from them, so we try using them ${randomSnodePool.length}`
|
|
);
|
|
|
|
// let this request try 3 (3+1) times. If all those requests end up without having a consensus,
|
|
// fetch the snode pool from one of the seed nodes (see the catch).
|
|
await pRetry(
|
|
async () => {
|
|
const commonNodes = await getSnodePoolFromSnodes();
|
|
|
|
if (!commonNodes || commonNodes.length < requiredSnodesForAgreement) {
|
|
// throwing makes trigger a retry if we have some left.
|
|
window?.log?.info(`refreshRandomPool: Not enough common nodes ${commonNodes?.length}`);
|
|
throw new Error('Not enough common nodes.');
|
|
}
|
|
window?.log?.info('updating snode list with snode pool length:', commonNodes.length);
|
|
randomSnodePool = commonNodes;
|
|
OnionPaths.resetPathFailureCount();
|
|
Onions.resetSnodeFailureCount();
|
|
|
|
await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool));
|
|
},
|
|
{
|
|
retries: 3,
|
|
factor: 1,
|
|
minTimeout: 1000,
|
|
onFailedAttempt: e => {
|
|
window?.log?.warn(
|
|
`getSnodePoolFromSnodes attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...`
|
|
);
|
|
},
|
|
}
|
|
);
|
|
} catch (e) {
|
|
window?.log?.warn(
|
|
'Failed to fetch snode pool from snodes. Fetching from seed node instead:',
|
|
e
|
|
);
|
|
|
|
// fallback to a seed node fetch of the snode pool
|
|
randomSnodePool = await exports.refreshRandomPoolDetail(seedNodes);
|
|
|
|
OnionPaths.resetPathFailureCount();
|
|
Onions.resetSnodeFailureCount();
|
|
await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drop a snode from the list of swarm for that specific publicKey
|
|
* @param pubkey the associatedWith publicKey
|
|
* @param snodeToDropEd25519 the snode pubkey to drop
|
|
*/
|
|
export async function dropSnodeFromSwarmIfNeeded(
|
|
pubkey: string,
|
|
snodeToDropEd25519: string
|
|
): Promise<void> {
|
|
// this call either used the cache or fetch the swarm from the db
|
|
const existingSwarm = await getSwarmFromCacheOrDb(pubkey);
|
|
|
|
if (!existingSwarm.includes(snodeToDropEd25519)) {
|
|
return;
|
|
}
|
|
|
|
const updatedSwarm = existingSwarm.filter(ed25519 => ed25519 !== snodeToDropEd25519);
|
|
await internalUpdateSwarmFor(pubkey, updatedSwarm);
|
|
}
|
|
|
|
export async function updateSwarmFor(pubkey: string, snodes: Array<Data.Snode>): Promise<void> {
|
|
const edkeys = snodes.map((sn: Data.Snode) => sn.pubkey_ed25519);
|
|
await internalUpdateSwarmFor(pubkey, edkeys);
|
|
}
|
|
|
|
async function internalUpdateSwarmFor(pubkey: string, edkeys: Array<string>) {
|
|
// update our in-memory cache
|
|
swarmCache.set(pubkey, edkeys);
|
|
// write this change to the db
|
|
await Data.updateSwarmNodesForPubkey(pubkey, edkeys);
|
|
}
|
|
|
|
export async function getSwarmFromCacheOrDb(pubkey: string): Promise<Array<string>> {
|
|
// NOTE: important that maybeNodes is not [] here
|
|
const existingCache = swarmCache.get(pubkey);
|
|
if (existingCache === undefined) {
|
|
// First time access, no cache yet, let's try the database.
|
|
const nodes = await Data.getSwarmNodesForPubkey(pubkey);
|
|
// if no db entry, this returns []
|
|
swarmCache.set(pubkey, nodes);
|
|
return nodes;
|
|
}
|
|
// cache already set, use it
|
|
return existingCache;
|
|
}
|
|
|
|
/**
|
|
* This call fetch from cache or db the swarm and extract only the one currently reachable.
|
|
* If not enough snodes valid are in the swarm, if fetches new snodes for this pubkey from the network.
|
|
*/
|
|
export async function getSwarmFor(pubkey: string): Promise<Array<Data.Snode>> {
|
|
const nodes = await getSwarmFromCacheOrDb(pubkey);
|
|
|
|
// See how many are actually still reachable
|
|
// the nodes still reachable are the one still present in the snode pool
|
|
const goodNodes = randomSnodePool.filter(
|
|
(n: Data.Snode) => nodes.indexOf(n.pubkey_ed25519) !== -1
|
|
);
|
|
|
|
if (goodNodes.length >= minSwarmSnodeCount) {
|
|
return goodNodes;
|
|
}
|
|
|
|
// Request new node list from the network
|
|
const freshNodes = _.shuffle(await requestSnodesForPubkey(pubkey));
|
|
|
|
const edkeys = freshNodes.map((n: Data.Snode) => n.pubkey_ed25519);
|
|
await internalUpdateSwarmFor(pubkey, edkeys);
|
|
|
|
return freshNodes;
|
|
}
|