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.
		
		
		
		
		
			
		
			
				
	
	
		
			385 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			385 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
| import semver from 'semver';
 | |
| 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';
 | |
| 
 | |
| /**
 | |
|  * 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;
 | |
|   port: number;
 | |
|   pubkey_x25519: string;
 | |
|   pubkey_ed25519: string;
 | |
|   version: string;
 | |
| }
 | |
| 
 | |
| // This should be renamed to `allNodes` or something
 | |
| let randomSnodePool: Array<Snode> = [];
 | |
| 
 | |
| // We only store nodes' identifiers here,
 | |
| const swarmCache: Map<string, Array<string>> = new Map();
 | |
| 
 | |
| export type SeedNode = {
 | |
|   url: string;
 | |
|   ip_url: string;
 | |
| };
 | |
| 
 | |
| // just get the filtered list
 | |
| async function tryGetSnodeListFromLokidSeednode(seedNodes: Array<SeedNode>): Promise<Array<Snode>> {
 | |
|   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, falling back to IP`,
 | |
|         seedNode.ip_url
 | |
|       );
 | |
|       // fall back on ip_url
 | |
|       const tryIpUrl = new URL(seedNode.ip_url);
 | |
|       snodes = await getSnodesFromSeedUrl(tryIpUrl);
 | |
|       if (snodes.length === 0) {
 | |
|         window?.log?.warn(
 | |
|           `loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.ip_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');
 | |
|       }
 | |
|     }
 | |
|     if (snodes.length) {
 | |
|       window?.log?.info(
 | |
|         `loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.url} returned ${snodes.length} snodes`
 | |
|       );
 | |
|     }
 | |
|     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 function dropSnodeFromSnodePool(snodeEd25519: string) {
 | |
|   const exists = _.some(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519);
 | |
|   if (exists) {
 | |
|     _.remove(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519);
 | |
| 
 | |
|     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<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 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 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<Snode>> {
 | |
|   await refreshRandomPool();
 | |
| 
 | |
|   return randomSnodePool;
 | |
| }
 | |
| 
 | |
| export async function getRandomSnodePool(): Promise<Array<Snode>> {
 | |
|   if (randomSnodePool.length === 0) {
 | |
|     await refreshRandomPool();
 | |
|   }
 | |
|   return randomSnodePool;
 | |
| }
 | |
| 
 | |
| // not cacheable because we write to this.randomSnodePool elsewhere
 | |
| export function getNodesMinVersion(minVersion: string): Array<Snode> {
 | |
|   return randomSnodePool.filter((node: any) => node.version && semver.gt(node.version, minVersion));
 | |
| }
 | |
| 
 | |
| async function getSnodeListFromLokidSeednode(
 | |
|   seedNodes: Array<SeedNode>,
 | |
|   retries = 0
 | |
| ): Promise<Array<Snode>> {
 | |
|   const SEED_NODE_RETRIES = 3;
 | |
| 
 | |
|   if (!seedNodes.length) {
 | |
|     window?.log?.info('loki_snode_api::getSnodeListFromLokidSeednode - seedNodes are empty');
 | |
|     return [];
 | |
|   }
 | |
|   let snodes: Array<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(() => {
 | |
|         window?.log?.info(
 | |
|           'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #',
 | |
|           retries,
 | |
|           'seed nodes total',
 | |
|           seedNodes.length
 | |
|         );
 | |
|         void getSnodeListFromLokidSeednode(seedNodes, retries + 1);
 | |
|       }, 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<Snode>> {
 | |
|   let snodes = [];
 | |
|   try {
 | |
|     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',
 | |
|       randomSnodePool.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(): Promise<void> {
 | |
|   if (!window.getSeedNodeList() || !window.getSeedNodeList().length) {
 | |
|     window?.log?.error(
 | |
|       'LokiSnodeAPI:::refreshRandomPool - getSeedNodeList has not been loaded yet'
 | |
|     );
 | |
| 
 | |
|     return;
 | |
|   }
 | |
|   // tslint:disable-next-line:no-parameter-reassignment
 | |
|   const seedNodes = window.getSeedNodeList();
 | |
| 
 | |
|   return allowOnlyOneAtATime('refreshRandomPool', async () => {
 | |
|     // we don't have nodes to fetch the pool from them, so call the seed node instead.
 | |
|     if (randomSnodePool.length < minSnodePoolCount) {
 | |
|       randomSnodePool = await exports.refreshRandomPoolDetail(seedNodes);
 | |
| 
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       // 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.
 | |
|             throw new Error('Not enough common nodes.');
 | |
|           }
 | |
|           window?.log?.info('updating snode list with snode pool length:', commonNodes.length);
 | |
|           randomSnodePool = commonNodes;
 | |
|         },
 | |
|         {
 | |
|           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);
 | |
|     }
 | |
|   });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * 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<Snode>): Promise<void> {
 | |
|   const edkeys = snodes.map((sn: 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<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: 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: Snode) => n.pubkey_ed25519);
 | |
|   await internalUpdateSwarmFor(pubkey, edkeys);
 | |
| 
 | |
|   return freshNodes;
 | |
| }
 |