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.
		
		
		
		
		
			
		
			
				
	
	
		
			309 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			309 lines
		
	
	
		
			8.9 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';
 | 
						|
import { UserUtils } from '../utils';
 | 
						|
 | 
						|
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 = UserUtils.getOurPubKeyStrFromCache();
 | 
						|
    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();
 |