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.
		
		
		
		
		
			
		
			
				
	
	
		
			308 lines
		
	
	
		
			8.8 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			308 lines
		
	
	
		
			8.8 KiB
		
	
	
	
		
			TypeScript
		
	
| import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
 | |
| import * as Data from '../../../js/modules/data';
 | |
| import * as SnodePool from '../snode_api/snodePool';
 | |
| import _ from 'lodash';
 | |
| import fetch from 'node-fetch';
 | |
| 
 | |
| type Snode = SnodePool.Snode;
 | |
| 
 | |
| interface SnodePath {
 | |
|   path: Array<Snode>;
 | |
|   bad: boolean;
 | |
| }
 | |
| 
 | |
| class OnionPaths {
 | |
|   private onionPaths: Array<SnodePath> = [];
 | |
| 
 | |
|   // This array is meant to store nodes will full info,
 | |
|   // so using GuardNode would not be correct (there is
 | |
|   // some naming issue here it seems)
 | |
|   private guardNodes: Array<Snode> = [];
 | |
|   private onionRequestCounter = 0; // Request index for debugging
 | |
| 
 | |
|   public async buildNewOnionPaths() {
 | |
|     // this function may be called concurrently make sure we only have one inflight
 | |
|     return allowOnlyOneAtATime('buildNewOnionPaths', async () => {
 | |
|       await this.buildNewOnionPathsWorker();
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   public async getOnionPath(toExclude?: {
 | |
|     pubkey_ed25519: string;
 | |
|   }): Promise<Array<Snode>> {
 | |
|     const { log, CONSTANTS } = window;
 | |
| 
 | |
|     let goodPaths = this.onionPaths.filter(x => !x.bad);
 | |
| 
 | |
|     let attemptNumber = 0;
 | |
|     while (goodPaths.length < CONSTANTS.MIN_GUARD_COUNT) {
 | |
|       log.error(
 | |
|         `Must have at least 2 good onion paths, actual: ${goodPaths.length}, attempt #${attemptNumber} fetching more...`
 | |
|       );
 | |
|       // eslint-disable-next-line no-await-in-loop
 | |
|       await this.buildNewOnionPaths();
 | |
|       // should we add a delay? buildNewOnionPaths should act as one
 | |
| 
 | |
|       // reload goodPaths now
 | |
|       attemptNumber += 1;
 | |
|       goodPaths = this.onionPaths.filter(x => !x.bad);
 | |
|     }
 | |
| 
 | |
|     const paths = _.shuffle(goodPaths);
 | |
| 
 | |
|     if (!toExclude) {
 | |
|       if (!paths[0]) {
 | |
|         log.error('LokiSnodeAPI::getOnionPath - no path in', paths);
 | |
|         return [];
 | |
|       }
 | |
|       if (!paths[0].path) {
 | |
|         log.error('LokiSnodeAPI::getOnionPath - no path in', paths[0]);
 | |
|       }
 | |
|       return paths[0].path;
 | |
|     }
 | |
| 
 | |
|     // Select a path that doesn't contain `toExclude`
 | |
|     const otherPaths = paths.filter(
 | |
|       path =>
 | |
|         !_.some(
 | |
|           path.path,
 | |
|           node => node.pubkey_ed25519 === toExclude.pubkey_ed25519
 | |
|         )
 | |
|     );
 | |
| 
 | |
|     if (otherPaths.length === 0) {
 | |
|       // This should never happen!
 | |
|       // well it did happen, should we
 | |
|       // await this.buildNewOnionPaths();
 | |
|       // and restart call?
 | |
|       log.error(
 | |
|         'LokiSnodeAPI::getOnionPath - no paths without',
 | |
|         toExclude.pubkey_ed25519,
 | |
|         'path count',
 | |
|         paths.length,
 | |
|         'goodPath count',
 | |
|         goodPaths.length,
 | |
|         'paths',
 | |
|         paths
 | |
|       );
 | |
|       throw new Error('No onion paths available after filtering');
 | |
|     }
 | |
| 
 | |
|     if (!otherPaths[0].path) {
 | |
|       log.error(
 | |
|         'LokiSnodeAPI::getOnionPath - otherPaths no path in',
 | |
|         otherPaths[0]
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return otherPaths[0].path;
 | |
|   }
 | |
| 
 | |
|   public markPathAsBad(path: Array<Snode>) {
 | |
|     // TODO: we might want to remove the nodes from the
 | |
|     // node pool (but we don't know which node on the path
 | |
|     // is causing issues)
 | |
| 
 | |
|     this.onionPaths.forEach(p => {
 | |
|       if (_.isEqual(p.path, path)) {
 | |
|         // eslint-disable-next-line no-param-reassign
 | |
|         p.bad = true;
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   public assignOnionRequestNumber() {
 | |
|     this.onionRequestCounter += 1;
 | |
|     return this.onionRequestCounter;
 | |
|   }
 | |
| 
 | |
|   private async testGuardNode(snode: Snode) {
 | |
|     const { log } = window;
 | |
| 
 | |
|     log.info('Testing a candidate guard node ', snode);
 | |
| 
 | |
|     // Send a post request and make sure it is OK
 | |
|     const endpoint = '/storage_rpc/v1';
 | |
| 
 | |
|     const url = `https://${snode.ip}:${snode.port}${endpoint}`;
 | |
| 
 | |
|     const ourPK = window.textsecure.storage.user.getNumber();
 | |
|     const pubKey = window.getStoragePubKey(ourPK); // truncate if testnet
 | |
| 
 | |
|     const method = 'get_snodes_for_pubkey';
 | |
|     const params = { pubKey };
 | |
|     const body = {
 | |
|       jsonrpc: '2.0',
 | |
|       id: '0',
 | |
|       method,
 | |
|       params,
 | |
|     };
 | |
| 
 | |
|     const fetchOptions = {
 | |
|       method: 'POST',
 | |
|       body: JSON.stringify(body),
 | |
|       headers: { 'Content-Type': 'application/json' },
 | |
|       timeout: 10000, // 10s, we want a smaller timeout for testing
 | |
|     };
 | |
| 
 | |
|     process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 | |
| 
 | |
|     let response;
 | |
| 
 | |
|     try {
 | |
|       // Log this line for testing
 | |
|       // curl -k -X POST -H 'Content-Type: application/json' -d '"+fetchOptions.body.replace(/"/g, "\\'")+"'", url
 | |
|       response = await fetch(url, fetchOptions);
 | |
|     } catch (e) {
 | |
|       if (e.type === 'request-timeout') {
 | |
|         log.warn('test timeout for node,', snode);
 | |
|       }
 | |
|       return false;
 | |
|     } finally {
 | |
|       process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
 | |
|     }
 | |
| 
 | |
|     if (!response.ok) {
 | |
|       log.info('Node failed the guard test:', snode);
 | |
|     }
 | |
| 
 | |
|     return response.ok;
 | |
|   }
 | |
| 
 | |
|   private async selectGuardNodes(): Promise<Array<Snode>> {
 | |
|     const { CONSTANTS, log } = window;
 | |
| 
 | |
|     // `getRandomSnodePool` is expected to refresh itself on low nodes
 | |
|     const nodePool = await SnodePool.getRandomSnodePool();
 | |
|     if (nodePool.length < CONSTANTS.DESIRED_GUARD_COUNT) {
 | |
|       log.error(
 | |
|         'Could not select guard nodes. Not enough nodes in the pool: ',
 | |
|         nodePool.length
 | |
|       );
 | |
|       return [];
 | |
|     }
 | |
| 
 | |
|     const shuffled = _.shuffle(nodePool);
 | |
| 
 | |
|     let guardNodes: Array<Snode> = [];
 | |
| 
 | |
|     // The use of await inside while is intentional:
 | |
|     // 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) {
 | |
|         log.error('Not enought nodes in the pool');
 | |
|         break;
 | |
|       }
 | |
| 
 | |
|       const candidateNodes = shuffled.splice(0, CONSTANTS.DESIRED_GUARD_COUNT);
 | |
| 
 | |
|       // Test all three nodes at once
 | |
|       // eslint-disable-next-line no-await-in-loop
 | |
|       const idxOk = await Promise.all(
 | |
|         candidateNodes.map(n => this.testGuardNode(n))
 | |
|       );
 | |
| 
 | |
|       const goodNodes = _.zip(idxOk, candidateNodes)
 | |
|         .filter(x => x[0])
 | |
|         .map(x => x[1]) as Array<Snode>;
 | |
| 
 | |
|       guardNodes = _.concat(guardNodes, goodNodes);
 | |
|     }
 | |
| 
 | |
|     if (guardNodes.length < CONSTANTS.DESIRED_GUARD_COUNT) {
 | |
|       log.error(
 | |
|         `COULD NOT get enough guard nodes, only have: ${guardNodes.length}`
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     log.info('new guard nodes: ', guardNodes);
 | |
| 
 | |
|     const edKeys = guardNodes.map(n => n.pubkey_ed25519);
 | |
| 
 | |
|     await window.libloki.storage.updateGuardNodes(edKeys);
 | |
| 
 | |
|     return guardNodes;
 | |
|   }
 | |
| 
 | |
|   private async buildNewOnionPathsWorker() {
 | |
|     const { CONSTANTS, log } = window;
 | |
| 
 | |
|     log.info('LokiSnodeAPI::buildNewOnionPaths - building new onion paths');
 | |
| 
 | |
|     const allNodes = await SnodePool.getRandomSnodePool();
 | |
| 
 | |
|     if (this.guardNodes.length === 0) {
 | |
|       // Not cached, load from DB
 | |
|       const nodes = await Data.getGuardNodes();
 | |
| 
 | |
|       if (nodes.length === 0) {
 | |
|         log.warn(
 | |
|           'LokiSnodeAPI::buildNewOnionPaths - no guard nodes in DB. Will be selecting new guards nodes...'
 | |
|         );
 | |
|       } else {
 | |
|         // We only store the nodes' keys, need to find full entries:
 | |
|         const edKeys = nodes.map(x => x.ed25519PubKey);
 | |
|         this.guardNodes = allNodes.filter(
 | |
|           x => edKeys.indexOf(x.pubkey_ed25519) !== -1
 | |
|         );
 | |
| 
 | |
|         if (this.guardNodes.length < edKeys.length) {
 | |
|           log.warn(
 | |
|             `LokiSnodeAPI::buildNewOnionPaths - could not find some guard nodes: ${this.guardNodes.length}/${edKeys.length} left`
 | |
|           );
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // If guard nodes is still empty (the old nodes are now invalid), select new ones:
 | |
|       if (this.guardNodes.length < CONSTANTS.MIN_GUARD_COUNT) {
 | |
|         // TODO: don't throw away potentially good guard nodes
 | |
|         this.guardNodes = await this.selectGuardNodes();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // TODO: select one guard node and 2 other nodes randomly
 | |
|     let otherNodes = _.difference(allNodes, this.guardNodes);
 | |
| 
 | |
|     if (otherNodes.length < 2) {
 | |
|       log.warn(
 | |
|         'LokiSnodeAPI::buildNewOnionPaths - Too few nodes to build an onion path! Refreshing pool and retrying'
 | |
|       );
 | |
|       await SnodePool.refreshRandomPool();
 | |
|       await this.buildNewOnionPaths();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     otherNodes = _.shuffle(otherNodes);
 | |
|     const guards = _.shuffle(this.guardNodes);
 | |
| 
 | |
|     // Create path for every guard node:
 | |
|     const nodesNeededPerPaths = window.lokiFeatureFlags.onionRequestHops - 1;
 | |
| 
 | |
|     // Each path needs X (nodesNeededPerPaths) nodes in addition to the guard node:
 | |
|     const maxPath = Math.floor(
 | |
|       Math.min(
 | |
|         guards.length,
 | |
|         nodesNeededPerPaths
 | |
|           ? otherNodes.length / nodesNeededPerPaths
 | |
|           : otherNodes.length
 | |
|       )
 | |
|     );
 | |
| 
 | |
|     // TODO: might want to keep some of the existing paths
 | |
|     this.onionPaths = [];
 | |
| 
 | |
|     for (let i = 0; i < maxPath; i += 1) {
 | |
|       const path = [guards[i]];
 | |
|       for (let j = 0; j < nodesNeededPerPaths; j += 1) {
 | |
|         path.push(otherNodes[i * nodesNeededPerPaths + j]);
 | |
|       }
 | |
|       this.onionPaths.push({ path, bad: false });
 | |
|     }
 | |
| 
 | |
|     log.info(`Built ${this.onionPaths.length} onion paths`, this.onionPaths);
 | |
|   }
 | |
| }
 | |
| 
 | |
| export const OnionAPI = new OnionPaths();
 |