diff --git a/preload.js b/preload.js index 829de8dbb9..b625d604e0 100644 --- a/preload.js +++ b/preload.js @@ -76,6 +76,8 @@ window.sessionFeatureFlags = { debugLibsessionDumps: !isEmpty(process.env.SESSION_DEBUG_LIBSESSION_DUMPS), debugBuiltSnodeRequests: !isEmpty(process.env.SESSION_DEBUG_BUILT_SNODE_REQUEST), debugSwarmPolling: !isEmpty(process.env.SESSION_DEBUG_SWARM_POLLING), + debugOnionPaths: !isEmpty(process.env.SESSION_DEBUG_ONION_PATHS), + debugSnodePool: !isEmpty(process.env.SESSION_DEBUG_SNODE_POOL), debugServerRequests: false, debugNonSnodeRequests: false, debugOnionRequests: false, diff --git a/ts/data/types.ts b/ts/data/types.ts index f34d3bbe0a..d4fd62200a 100644 --- a/ts/data/types.ts +++ b/ts/data/types.ts @@ -4,14 +4,6 @@ import type { WithServerUrl, } from '../session/apis/open_group_api/sogsv3/sogsWith'; -export type IdentityKey = { - id: string; - publicKey: ArrayBuffer; - firstUse: boolean; - nonblockingApproval: boolean; - secretKey?: string; // found in medium groups -}; - export type GuardNode = { ed25519PubKey: string; }; diff --git a/ts/session/apis/snode_api/getSwarmFor.ts b/ts/session/apis/snode_api/getSwarmFor.ts index c5a9ef7f60..8aebdd8f22 100644 --- a/ts/session/apis/snode_api/getSwarmFor.ts +++ b/ts/session/apis/snode_api/getSwarmFor.ts @@ -97,7 +97,7 @@ async function requestSnodesForPubkeyRetryable(pubKey: string): Promise { - const targetNode = await SnodePool.getRandomSnode(); + const targetNode = await SnodePool.getRandomSnode({ snodesToExclude: [] }); return requestSnodesForPubkeyWithTargetNode(pubKey, targetNode); }, diff --git a/ts/session/apis/snode_api/onions.ts b/ts/session/apis/snode_api/onions.ts index 7cde11b417..04621b9c75 100644 --- a/ts/session/apis/snode_api/onions.ts +++ b/ts/session/apis/snode_api/onions.ts @@ -25,6 +25,7 @@ import { WithDestinationEd25519, WithGuardNode, WithSymmetricKey, + type WithReason, } from '../../types/with'; import { updateIsOnline } from '../../../state/ducks/onions'; import { SERVER_HOSTS } from '..'; @@ -347,21 +348,24 @@ export async function processOnionRequestErrorAtDestination({ async function handleNodeNotFound({ ed25519NotFound, associatedWith, -}: Partial & { - ed25519NotFound: string; -}) { - const shortNodeNotFound = ed25519Str(ed25519NotFound); - window?.log?.warn('Handling NODE NOT FOUND with: ', shortNodeNotFound); + reason, +}: Partial & + WithReason & { + ed25519NotFound: string; + }) { + const shortened = ed25519Str(ed25519NotFound); + + window?.log?.warn(`Handling NODE NOT FOUND with: ${shortened}, reason: "${reason}"`); if (associatedWith) { - await SnodePool.dropSnodeFromSwarmIfNeeded(associatedWith, ed25519NotFound); + await SnodePool.dropSnodeFromSwarmIfNeeded(associatedWith, ed25519NotFound, reason); } - await SnodePool.dropSnodeFromSnodePool(ed25519NotFound); + await SnodePool.dropSnodeFromSnodePool(ed25519NotFound, reason); snodeFailureCount[ed25519NotFound] = 0; // try to remove the not found snode from any of the paths if it's there. // it may not be here, as the snode note found might be the target snode of the request. - await OnionPaths.dropSnodeFromPath(ed25519NotFound); + await OnionPaths.dropSnodeFromPath(ed25519NotFound, reason); } async function processAnyOtherErrorOnPath( @@ -379,11 +383,18 @@ async function processAnyOtherErrorOnPath( const nodeNotFound = ciphertext.substr(NEXT_NODE_NOT_FOUND_PREFIX.length); // we are checking errors on the path, a nodeNotFound on the path should trigger a rebuild - await handleNodeNotFound({ ed25519NotFound: nodeNotFound, associatedWith }); + await handleNodeNotFound({ + ed25519NotFound: nodeNotFound, + associatedWith, + reason: 'processAnyOtherErrorOnPath NEXT_NODE_NOT_FOUND_PREFIX', + }); } else { // Otherwise we increment the whole path failure count - await incrementBadPathCountOrDrop(guardNodeEd25519); + await incrementBadPathCountOrDrop( + guardNodeEd25519, + 'processAnyOtherErrorOnPath: Otherwise we increment the whole path failure count' + ); } processOxenServerError(status, ciphertext); @@ -412,6 +423,7 @@ async function processAnyOtherErrorAtDestination( await handleNodeNotFound({ ed25519NotFound: nodeNotFound, associatedWith, + reason: 'processAnyOtherErrorAtDestination NEXT_NODE_NOT_FOUND_PREFIX', }); // We have to retry with another targetNode so it's not just rebuilding the path. We have to go one lever higher (lokiOnionFetch). @@ -424,6 +436,7 @@ async function processAnyOtherErrorAtDestination( await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519: destinationEd25519, associatedWith, + reason: `processAnyOtherErrorAtDestination: ${status} for snodeDestinationEd25519: ${ed25519Str(destinationEd25519)}, associatedWith: ${associatedWith ? ed25519Str(associatedWith) : 'null'}, bodySliced: "${body?.slice(0, 300)}"`, }); throw new Error(`Bad Path handled. Retry this request. Status: ${status}`); } @@ -619,12 +632,11 @@ async function processNoSymmetricKeyError( symmetricKey?: ArrayBuffer ): Promise { if (!symmetricKey) { - const errorMsg = - 'No symmetric key to decode response, probably a time out on the onion request itself'; + const errorMsg = `No symmetric key to decode response, probably a time out on the onion request itself with guardNode: ${ed25519Str(guardNode.pubkey_ed25519)}`; window?.log?.error(errorMsg); - await incrementBadPathCountOrDrop(guardNode.pubkey_ed25519); + await incrementBadPathCountOrDrop(guardNode.pubkey_ed25519, errorMsg); throw new Error(errorMsg); } @@ -738,20 +750,24 @@ async function handle421InvalidSwarm({ throw new pRetry.AbortError(ERROR_421_HANDLED_RETRY_REQUEST); } // remove this node from the swarm of this pubkey - await SnodePool.dropSnodeFromSwarmIfNeeded(associatedWith, destinationSnodeEd25519); + await SnodePool.dropSnodeFromSwarmIfNeeded( + associatedWith, + destinationSnodeEd25519, + 'handle421InvalidSwarm' + ); } catch (e) { if (e.message !== ERROR_421_HANDLED_RETRY_REQUEST) { - window?.log?.warn( - 'Got error while parsing 421 result. Dropping this snode from the swarm of this pubkey', - e - ); + const errorStr = + 'Got error while parsing 421 result. Dropping this snode from the swarm of this pubkey'; + window?.log?.warn(errorStr, e.message); // could not parse result. Consider that this snode as invalid - await SnodePool.dropSnodeFromSwarmIfNeeded(associatedWith, destinationSnodeEd25519); + await SnodePool.dropSnodeFromSwarmIfNeeded(associatedWith, destinationSnodeEd25519, errorStr); } } await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519: destinationSnodeEd25519, associatedWith, + reason: 'handle421InvalidSwarm', }); // this is important we throw so another retry is made and we exit the handling of that response @@ -774,29 +790,34 @@ async function handle421InvalidSwarm({ async function incrementBadSnodeCountOrDrop({ snodeEd25519, associatedWith, -}: Partial & { - snodeEd25519: string; -}) { + reason, +}: Partial & + WithReason & { + snodeEd25519: string; + }) { const oldFailureCount = snodeFailureCount[snodeEd25519] || 0; const newFailureCount = oldFailureCount + 1; snodeFailureCount[snodeEd25519] = newFailureCount; if (newFailureCount >= snodeFailureThreshold) { - window?.log?.warn( - `Failure threshold reached for snode: ${ed25519Str(snodeEd25519)}; dropping it.` - ); + const errorStr = `Failure threshold reached for snode: ${ed25519Str(snodeEd25519)}; dropping it.`; + window?.log?.warn(errorStr); if (associatedWith) { - await SnodePool.dropSnodeFromSwarmIfNeeded(associatedWith, snodeEd25519); + await SnodePool.dropSnodeFromSwarmIfNeeded( + associatedWith, + snodeEd25519, + `${errorStr} (${reason})` + ); } - await SnodePool.dropSnodeFromSnodePool(snodeEd25519); + await SnodePool.dropSnodeFromSnodePool(snodeEd25519, `${errorStr} (${reason})`); snodeFailureCount[snodeEd25519] = 0; - await OnionPaths.dropSnodeFromPath(snodeEd25519); + await OnionPaths.dropSnodeFromPath(snodeEd25519, `${errorStr} (${reason})`); } else { window?.log?.warn( `Couldn't reach snode at: ${ed25519Str( snodeEd25519 - )}; setting his failure count to ${newFailureCount}` + )}; setting his failure count to ${newFailureCount} with reason: (${reason})` ); } } diff --git a/ts/session/apis/snode_api/onsResolve.ts b/ts/session/apis/snode_api/onsResolve.ts index d1790a92bf..42d8090151 100644 --- a/ts/session/apis/snode_api/onsResolve.ts +++ b/ts/session/apis/snode_api/onsResolve.ts @@ -34,7 +34,7 @@ async function getSessionIDForOnsName(onsNameCase: string) { // we do this request with validationCount snodes const promises = range(0, validationCount).map(async () => { - const targetNode = await SnodePool.getRandomSnode(); + const targetNode = await SnodePool.getRandomSnode({ snodesToExclude: [] }); const results = await BatchRequests.doUnsignedSnodeBatchRequestNoRetries({ unsignedSubRequests: [subRequest], diff --git a/ts/session/apis/snode_api/snodePool.ts b/ts/session/apis/snode_api/snodePool.ts index cbbdb6183d..3bda96fbd5 100644 --- a/ts/session/apis/snode_api/snodePool.ts +++ b/ts/session/apis/snode_api/snodePool.ts @@ -1,4 +1,4 @@ -import _, { isEmpty, sample, shuffle } from 'lodash'; +import _, { fill, flatten, groupBy, isEmpty, map, pick, sample, shuffle } from 'lodash'; import pRetry from 'p-retry'; import { Data } from '../../../data/data'; @@ -11,6 +11,8 @@ import { requestSnodesForPubkeyFromNetwork } from './getSwarmFor'; import { Onions } from '.'; import { ed25519Str } from '../../utils/String'; import { SnodePoolConstants } from './snodePoolConstants'; +import { stringify } from '../../../types/sqlSharedTypes'; +import { logDebugWithCat } from '../../../util/logger/debugLog'; let randomSnodePool: Array = []; @@ -22,17 +24,19 @@ function TEST_resetState(snodePoolForTest: Array = []) { // We only store nodes' identifiers here, const swarmCache: Map> = new Map(); +const logPrefix = '[snodePool]'; + /** * 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 */ -async function dropSnodeFromSnodePool(snodeEd25519: string) { +async function dropSnodeFromSnodePool(snodeEd25519: string, reason: string) { const exists = _.some(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519); if (exists) { _.remove(randomSnodePool, x => x.pubkey_ed25519 === snodeEd25519); window?.log?.warn( - `Dropping ${ed25519Str(snodeEd25519)} from snode pool. ${ + `${logPrefix} Dropping ${ed25519Str(snodeEd25519)} from snode pool for reason: "${reason}". ${ randomSnodePool.length } snodes remaining in randomPool` ); @@ -45,24 +49,29 @@ async function dropSnodeFromSnodePool(snodeEd25519: string) { * excludingEd25519Snode can be used to exclude some nodes from the random list. * Useful to rebuild a path excluding existing node already in a path */ -async function getRandomSnode(excludingEd25519Snode?: Array): Promise { +async function getRandomSnode({ + snodesToExclude, +}: { + snodesToExclude: Array; +}): Promise { // make sure we have a few snodes in the pool excluding the one passed as args - const requiredCount = SnodePoolConstants.minSnodePoolCount + (excludingEd25519Snode?.length || 0); - if (randomSnodePool.length < requiredCount) { - await SnodePool.getSnodePoolFromDBOrFetchFromSeed(excludingEd25519Snode?.length); + const extraCountToAdd = snodesToExclude.length; + const requestedCount = SnodePoolConstants.minSnodePoolCount + extraCountToAdd; + if (randomSnodePool.length < requestedCount) { + await SnodePool.getSnodePoolFromDBOrFetchFromSeed(extraCountToAdd); - if (randomSnodePool.length < requiredCount) { + if (randomSnodePool.length < requestedCount) { window?.log?.warn( - `getRandomSnode: failed to fetch snodes from seed. Current pool: ${randomSnodePool.length}` + `${logPrefix} getRandomSnode: failed to fetch snodes from seed. Current pool: ${randomSnodePool.length}, requested count: ${requestedCount}` ); throw new Error( - `getRandomSnode: failed to fetch snodes from seed. Current pool: ${randomSnodePool.length}, required count: ${requiredCount}` + `getRandomSnode: failed to fetch snodes from seed. Current pool: ${randomSnodePool.length}, requested count: ${requestedCount}` ); } } // We know the pool can't be empty at this point - if (!excludingEd25519Snode) { + if (!snodesToExclude.length) { const snodePicked = sample(randomSnodePool); if (!snodePicked) { throw new Error('getRandomSnode failed as sample returned none '); @@ -70,18 +79,45 @@ async function getRandomSnode(excludingEd25519Snode?: Array): Promise !excludingEd25519Snode.includes(e.pubkey_ed25519) + // get an unmodified snode pool without the nodes to exclude either by pubkey or by subnet + const snodePoolWithoutExcluded = window.sessionFeatureFlags?.useLocalDevNet + ? randomSnodePool + : randomSnodePool.filter( + e => + !snodesToExclude.some(m => m.pubkey_ed25519 === e.pubkey_ed25519) && + !hasSnodeSameSubnetIp(snodesToExclude, e) + ); + + const weightedWithoutExcludedSnodes = getWeightedSingleSnodePerSubnet(snodePoolWithoutExcluded); + logDebugWithCat( + logPrefix, + `getRandomSnode: snodePoolNoFilter: ${stringify(randomSnodePool.map(m => pick(m, ['ip', 'pubkey_ed25519'])))}`, + window.sessionFeatureFlags.debugSnodePool ); - if (!snodePoolExcluding || !snodePoolExcluding.length) { + logDebugWithCat( + logPrefix, + `getRandomSnode: snodePoolWithoutExcluded: ${stringify(snodePoolWithoutExcluded.map(m => pick(m, ['ip', 'pubkey_ed25519'])))}`, + window.sessionFeatureFlags.debugSnodePool + ); + logDebugWithCat( + logPrefix, + `getRandomSnode: weightedWithoutExcludedSnodes: ${stringify(weightedWithoutExcludedSnodes.map(m => pick(m, ['ip', 'pubkey_ed25519'])))}`, + window.sessionFeatureFlags.debugSnodePool + ); + if (!weightedWithoutExcludedSnodes?.length) { // used for tests - throw new Error(`Not enough snodes with excluding length ${excludingEd25519Snode.length}`); + throw new Error(`Not enough snodes with snodes to exclude length:${snodesToExclude.length}`); } - const snodePicked = sample(snodePoolExcluding); + const snodePicked = sample(weightedWithoutExcludedSnodes); if (!snodePicked) { - throw new Error('getRandomSnode failed as sample returned none '); + throw new Error('getRandomSnode failed as sample returned none'); } + + logDebugWithCat( + logPrefix, + `getRandomSnode: snodePicked: ${stringify(snodePicked)}`, + window.sessionFeatureFlags.debugSnodePool + ); return snodePicked; } @@ -94,7 +130,7 @@ async function forceRefreshRandomSnodePool(): Promise> { await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); window?.log?.info( - `forceRefreshRandomSnodePool: enough snodes to fetch from them, so we try using them ${randomSnodePool.length}` + `${logPrefix} forceRefreshRandomSnodePool: enough snodes to fetch from them, so we try using them ${randomSnodePool.length}` ); // this function throws if it does not have enough snodes to do it @@ -104,7 +140,7 @@ async function forceRefreshRandomSnodePool(): Promise> { } } catch (e) { window?.log?.warn( - 'forceRefreshRandomSnodePool: Failed to fetch snode pool from snodes. Fetching from seed node instead:', + `${logPrefix} forceRefreshRandomSnodePool: Failed to fetch snode pool from snodes. Fetching from seed node instead:`, e.message ); @@ -113,7 +149,7 @@ async function forceRefreshRandomSnodePool(): Promise> { await SnodePool.TEST_fetchFromSeedWithRetriesAndWriteToDb(); } catch (err2) { window?.log?.warn( - 'forceRefreshRandomSnodePool: Failed to fetch snode pool from seed. Fetching from seed node instead:', + `${logPrefix} forceRefreshRandomSnodePool: Failed to fetch snode pool from seed. Fetching from seed node instead:`, err2.message ); } @@ -142,7 +178,7 @@ async function getSnodePoolFromDBOrFetchFromSeed( fetchedFromDb.length <= SnodePoolConstants.minSnodePoolCount + countToAddToRequirement ) { window?.log?.warn( - `getSnodePoolFromDBOrFetchFromSeed: not enough snodes in db (${fetchedFromDb?.length}), Fetching from seed node instead... ` + `${logPrefix} getSnodePoolFromDBOrFetchFromSeed: not enough snodes in db (${fetchedFromDb?.length}), Fetching from seed node instead... ` ); // if that fails to get enough snodes, even after retries, well we just have to retry later. // this call does not throw @@ -175,7 +211,7 @@ async function TEST_fetchFromSeedWithRetriesAndWriteToDb() { if (!seedNodes || !seedNodes.length) { window?.log?.error( - 'SessionSnodeAPI:::fetchFromSeedWithRetriesAndWriteToDb - getSeedNodeList has not been loaded yet' + `${logPrefix} fetchFromSeedWithRetriesAndWriteToDb - getSeedNodeList has not been loaded yet` ); return; @@ -184,13 +220,15 @@ async function TEST_fetchFromSeedWithRetriesAndWriteToDb() { try { randomSnodePool = await SeedNodeAPI.fetchSnodePoolFromSeedNodeWithRetries(seedNodes); await Data.updateSnodePoolOnDb(JSON.stringify(randomSnodePool)); - window.log.info(`fetchSnodePoolFromSeedNodeWithRetries took ${Date.now() - start}ms`); + window.log.info( + `${logPrefix} fetchSnodePoolFromSeedNodeWithRetries took ${Date.now() - start}ms` + ); OnionPaths.resetPathFailureCount(); Onions.resetSnodeFailureCount(); } catch (e) { window?.log?.error( - 'SessionSnodeAPI:::fetchFromSeedWithRetriesAndWriteToDb - Failed to fetch snode poll from seed node with retries. Error:', + `${logPrefix} fetchFromSeedWithRetriesAndWriteToDb - Failed to fetch snode poll from seed node with retries. Error:`, e ); } @@ -208,6 +246,42 @@ async function clearOutAllSnodesNotInPool(snodePool: Array) { swarmCache.clear(); } +function subnetOfIp(ip: string) { + if (ip.lastIndexOf('.') === -1) { + return ip; + } + return ip.slice(0, ip.lastIndexOf('.')); +} + +function snodeSameSubnetIp(snode1: Snode, snode2: Snode) { + return subnetOfIp(snode1.ip) === subnetOfIp(snode2.ip); +} + +function hasSnodeSameSubnetIp(snodes: Array, snode: Snode) { + return snodes.some(m => snodeSameSubnetIp(m, snode)); +} + +/** + * Given an array of nodes, this function returns an array of nodes where a random node of each subnet is picked + * and repeated as many times as the subnet was present. + * + * For instance: given the snode with ips: 10.0.0.{1,2,3}, 10.0.1.{1,2}, 10.0.2.{1,2,3,4} this function will return + * an array of where a + * - a random node of 10.0.0.{1,2,3} is picked and present 3 times + * - a random node of 10.0.1.{1,2} is picked and present 2 times + * - a random node of 10.0.2.{1,2,3,4} is picked and present 4 times + */ +function getWeightedSingleSnodePerSubnet(nodes: Array) { + // make sure to not reuse multiple times the same subnet /24 + const allNodesGroupedBySubnet24 = groupBy(nodes, n => subnetOfIp(n.ip)); + const oneNodeForEachSubnet24KeepingRatio = flatten( + map(allNodesGroupedBySubnet24, group => { + return fill(Array(group.length), sample(group) as Snode); + }) + ); + return oneNodeForEachSubnet24KeepingRatio; +} + /** * This function retries a few times to get a consensus between 3 snodes of at least 24 snodes in the snode pool. * @@ -226,12 +300,12 @@ async function tryToGetConsensusWithSnodesWithRetries() { if (!commonNodes || commonNodes.length < requiredSnodesForAgreement) { // throwing makes trigger a retry if we have some left. window?.log?.info( - `tryToGetConsensusWithSnodesWithRetries: Not enough common nodes ${commonNodes?.length}` + `${logPrefix} tryToGetConsensusWithSnodesWithRetries: Not enough common nodes ${commonNodes?.length}` ); throw new Error('Not enough common nodes.'); } window?.log?.info( - 'Got consensus: updating snode list with snode pool length:', + `${logPrefix} Got consensus: updating snode list with snode pool length:`, commonNodes.length ); randomSnodePool = commonNodes; @@ -247,7 +321,7 @@ async function tryToGetConsensusWithSnodesWithRetries() { minTimeout: 1000, onFailedAttempt: e => { window?.log?.warn( - `tryToGetConsensusWithSnodesWithRetries attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` + `${logPrefix} tryToGetConsensusWithSnodesWithRetries attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` ); }, } @@ -261,11 +335,12 @@ async function tryToGetConsensusWithSnodesWithRetries() { */ async function dropSnodeFromSwarmIfNeeded( pubkey: string, - snodeToDropEd25519: string + snodeToDropEd25519: string, + reason: string ): Promise { // this call either used the cache or fetch the swarm from the db window?.log?.warn( - `Dropping ${ed25519Str(snodeToDropEd25519)} from swarm of ${ed25519Str(pubkey)}` + `${logPrefix} Dropping ${ed25519Str(snodeToDropEd25519)} from swarm of ${ed25519Str(pubkey)} for reason: "${reason}"` ); const existingSwarm = await SnodePool.getSwarmFromCacheOrDb(pubkey); @@ -279,15 +354,15 @@ async function dropSnodeFromSwarmIfNeeded( } async function updateSwarmFor(pubkey: string, snodes: Array): Promise { - const edkeys = snodes.map((sn: Snode) => sn.pubkey_ed25519); - await internalUpdateSwarmFor(pubkey, edkeys); + const edKeys = snodes.map((sn: Snode) => sn.pubkey_ed25519); + await internalUpdateSwarmFor(pubkey, edKeys); } -async function internalUpdateSwarmFor(pubkey: string, edkeys: Array) { +async function internalUpdateSwarmFor(pubkey: string, edKeys: Array) { // update our in-memory cache - swarmCache.set(pubkey, edkeys); + swarmCache.set(pubkey, edKeys); // write this change to the db - await Data.updateSwarmNodesForPubkey(pubkey, edkeys); + await Data.updateSwarmNodesForPubkey(pubkey, edKeys); } async function getSwarmFromCacheOrDb(pubkey: string): Promise> { @@ -345,7 +420,7 @@ async function getNodeFromSwarmOrThrow(pubkey: string): Promise { } } window.log.warn( - `getNodeFromSwarmOrThrow: could not get one random node for pk ${ed25519Str(pubkey)}` + `${logPrefix} getNodeFromSwarmOrThrow: could not get one random node for pk ${ed25519Str(pubkey)}` ); throw new Error(`getNodeFromSwarmOrThrow: could not get one random node`); } @@ -365,8 +440,8 @@ async function getSwarmFromNetworkAndSave(pubkey: string) { const swarm = await requestSnodesForPubkeyFromNetwork(pubkey); const shuffledSwarm = shuffle(swarm); - const edkeys = shuffledSwarm.map((n: Snode) => n.pubkey_ed25519); - await internalUpdateSwarmFor(pubkey, edkeys); + const edKeys = shuffledSwarm.map((n: Snode) => n.pubkey_ed25519); + await internalUpdateSwarmFor(pubkey, edKeys); return shuffledSwarm; } @@ -378,6 +453,7 @@ export const SnodePool = { getRandomSnode, getRandomSnodePool, getSnodePoolFromDBOrFetchFromSeed, + getWeightedSingleSnodePerSubnet, // swarm dropSnodeFromSwarmIfNeeded, diff --git a/ts/session/onions/onionPath.ts b/ts/session/onions/onionPath.ts index c97c8e0cca..49ed3a075f 100644 --- a/ts/session/onions/onionPath.ts +++ b/ts/session/onions/onionPath.ts @@ -1,6 +1,18 @@ /* eslint-disable import/no-mutable-exports */ /* eslint-disable no-await-in-loop */ -import _, { compact, sample } from 'lodash'; +import { + cloneDeep, + compact, + concat, + differenceBy, + flattenDeep, + isEmpty, + isEqual, + sample, + shuffle, + some, + zip, +} from 'lodash'; import pRetry from 'p-retry'; // eslint-disable-next-line import/no-named-default import { default as insecureNodeFetch } from 'node-fetch'; @@ -21,6 +33,8 @@ import { SnodePool } from '../apis/snode_api/snodePool'; import { SnodePoolConstants } from '../apis/snode_api/snodePoolConstants'; import { desiredGuardCount, minimumGuardCount, ONION_REQUEST_HOPS } from './onionPathConstants'; import { OnionPathEmptyError } from '../utils/errors'; +import { stringify } from '../../types/sqlSharedTypes'; +import { logDebugWithCat } from '../../util/logger/debugLog'; export function getOnionPathMinTimeout() { return DURATION.SECONDS; @@ -33,11 +47,11 @@ export let onionPaths: Array> = []; * @returns a copy of the onion path currently used by the app. */ export const TEST_getTestOnionPath = () => { - return _.cloneDeep(onionPaths); + return cloneDeep(onionPaths); }; export const TEST_getTestguardNodes = () => { - return _.cloneDeep(guardNodes); + return cloneDeep(guardNodes); }; /** @@ -49,7 +63,8 @@ export const clearTestOnionPath = () => { guardNodes = []; }; -// +const logPrefix = '[onionPaths]'; + /** * hold the failure count of the path starting with the snode ed25519 pubkey. * exported just for tests. do not interact with this directly @@ -74,7 +89,7 @@ export async function buildNewOnionPathsOneAtATime() { try { await buildNewOnionPathsWorker(); } catch (e) { - window?.log?.warn(`buildNewOnionPathsWorker failed with ${e.message}`); + window?.log?.warn(`${logPrefix} buildNewOnionPathsWorker failed with ${e.message}`); } }); } @@ -91,23 +106,25 @@ export async function buildNewOnionPathsOneAtATime() { * * @param snodeEd25519 the snode pubkey to drop */ -export async function dropSnodeFromPath(snodeEd25519: string) { +export async function dropSnodeFromPath(snodeEd25519: string, reason: string) { const pathWithSnodeIndex = onionPaths.findIndex(path => path.some(snode => snode.pubkey_ed25519 === snodeEd25519) ); if (pathWithSnodeIndex === -1) { - window?.log?.warn(`Could not drop ${ed25519Str(snodeEd25519)} as it is not in any paths`); + window?.log?.warn( + `${logPrefix} Could not drop ${ed25519Str(snodeEd25519)} as it is not in any paths` + ); // this can happen for instance if the snode given is the destination snode. // like a `retrieve` request returns node not found being the request the snode is made to. // in this case, nothing bad is happening for the path. We just have to use another snode to do the request return; } window?.log?.info( - `dropping snode ${ed25519Str(snodeEd25519)} from path index: ${pathWithSnodeIndex}` + `${logPrefix} dropping snode ${ed25519Str(snodeEd25519)} from path index: ${pathWithSnodeIndex} with reason: "${reason}"` ); // make a copy now so we don't alter the real one while doing stuff here - const oldPaths = _.cloneDeep(onionPaths); + const oldPaths = cloneDeep(onionPaths); let pathToPatchUp = oldPaths[pathWithSnodeIndex]; // remove the snode causing issue from this path @@ -118,14 +135,44 @@ export async function dropSnodeFromPath(snodeEd25519: string) { return; } + // Note: make sure to do this deep copy before removing the snode causing issue from pathToPatchUp + const allSnodesInPathToPatchUp = cloneDeep(pathToPatchUp); pathToPatchUp = pathToPatchUp.filter(snode => snode.pubkey_ed25519 !== snodeEd25519); - - const ed25519KeysToExclude = _.flattenDeep(oldPaths).map(m => m.pubkey_ed25519); + const toExcludeFromRandomSnode = flattenDeep(allSnodesInPathToPatchUp); + logDebugWithCat( + logPrefix, + `pathToPatchUp: ${stringify(pathToPatchUp)}`, + window.sessionFeatureFlags.debugOnionPaths + ); + logDebugWithCat( + logPrefix, + `toExcludeFromRandomSnode: ${stringify(toExcludeFromRandomSnode)}`, + window.sessionFeatureFlags.debugOnionPaths + ); // this call throws if it cannot return a valid snode. - const snodeToAppendToPath = await SnodePool.getRandomSnode(ed25519KeysToExclude); + const snodeToAppendToPath = await SnodePool.getRandomSnode({ + snodesToExclude: toExcludeFromRandomSnode, + }); + logDebugWithCat( + logPrefix, + `random snode selected: ${stringify(snodeToAppendToPath)}`, + window.sessionFeatureFlags.debugOnionPaths + ); + // Don't test the new snode as this would reveal the user's IP pathToPatchUp.push(snodeToAppendToPath); + logDebugWithCat( + logPrefix, + `onionPaths[${pathWithSnodeIndex}] before: ${stringify(onionPaths[pathWithSnodeIndex])}`, + window.sessionFeatureFlags.debugOnionPaths + ); + onionPaths[pathWithSnodeIndex] = pathToPatchUp; + logDebugWithCat( + logPrefix, + `onionPaths[${pathWithSnodeIndex}] after: ${stringify(onionPaths[pathWithSnodeIndex])}`, + window.sessionFeatureFlags.debugOnionPaths + ); } export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promise> { @@ -134,13 +181,13 @@ export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promis // the buildNewOnionPathsOneAtATime will try to fetch from seed if it needs more snodes while (onionPaths.length < minimumGuardCount) { window?.log?.info( - `getOnionPath: Must have at least ${minimumGuardCount} good onion paths, actual: ${onionPaths.length}, attempt #${attemptNumber}` + `${logPrefix} Must have at least ${minimumGuardCount} good onion paths, actual: ${onionPaths.length}, attempt #${attemptNumber}` ); try { // eslint-disable-next-line no-await-in-loop await buildNewOnionPathsOneAtATime(); } catch (e) { - window?.log?.warn(`buildNewOnionPathsOneAtATime failed with ${e.message}`); + window?.log?.warn(`${logPrefix} buildNewOnionPathsOneAtATime failed with ${e.message}`); } // should we add a delay? buildNewOnionPathsOneA tATime should act as one @@ -148,14 +195,14 @@ export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promis attemptNumber += 1; if (attemptNumber >= 10) { - window?.log?.error('Failed to get an onion path after 10 attempts'); + window?.log?.error(`${logPrefix} Failed to get an onion path after 10 attempts`); throw new Error(`Failed to build enough onion paths, current count: ${onionPaths.length}`); } } onionPaths = onionPaths.map(compact); if (onionPaths.length === 0) { - if (!_.isEmpty(window.inboxStore?.getState().onionPaths.snodePaths)) { + if (!isEmpty(window.inboxStore?.getState().onionPaths.snodePaths)) { window.inboxStore?.dispatch(updateOnionPaths([])); } } else { @@ -167,7 +214,7 @@ export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promis return { ip: c.ip }; }) ); - if (!_.isEqual(window.inboxStore?.getState().onionPaths.snodePaths, ipsOnly)) { + if (!isEqual(window.inboxStore?.getState().onionPaths.snodePaths, ipsOnly)) { window.inboxStore?.dispatch(updateOnionPaths(ipsOnly)); } } @@ -177,7 +224,7 @@ export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promis if (!onionPaths || onionPaths.length === 0) { throw new OnionPathEmptyError(); } - const randomPathNoExclude = _.sample(onionPaths); + const randomPathNoExclude = sample(onionPaths); if (!randomPathNoExclude) { throw new OnionPathEmptyError(); } @@ -186,13 +233,13 @@ export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promis // here we got a snode to exclude from the returned path const onionPathsWithoutExcluded = onionPaths.filter( - path => !_.some(path, node => node.pubkey_ed25519 === toExclude.pubkey_ed25519) + path => !some(path, node => node.pubkey_ed25519 === toExclude.pubkey_ed25519) ); if (!onionPathsWithoutExcluded || onionPathsWithoutExcluded.length === 0) { throw new OnionPathEmptyError(); } - const randomPath = _.sample(onionPathsWithoutExcluded); + const randomPath = sample(onionPathsWithoutExcluded); if (!randomPath) { throw new OnionPathEmptyError(); } @@ -202,15 +249,17 @@ export async function getOnionPath({ toExclude }: { toExclude?: Snode }): Promis /** * If we don't know which nodes is causing trouble, increment the issue with this full path. */ -export async function incrementBadPathCountOrDrop(snodeEd25519: string) { +export async function incrementBadPathCountOrDrop(snodeEd25519: string, reason: string) { const pathWithSnodeIndex = onionPaths.findIndex(path => path.some(snode => snode.pubkey_ed25519 === snodeEd25519) ); if (pathWithSnodeIndex === -1) { - window?.log?.info('incrementBadPathCountOrDrop: Did not find any path containing this snode'); + window?.log?.info( + `${logPrefix} incrementBadPathCountOrDrop: Did not find any path containing this snode (reason was "${reason}")` + ); // this might happen if the snodeEd25519 is the one of the target snode, just increment the target snode count by 1 - await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519 }); + await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519, reason }); return undefined; } @@ -218,12 +267,14 @@ export async function incrementBadPathCountOrDrop(snodeEd25519: string) { const guardNodeEd25519 = onionPaths[pathWithSnodeIndex][0].pubkey_ed25519; window?.log?.info( - `incrementBadPathCountOrDrop starting with guard ${ed25519Str(guardNodeEd25519)}` + `${logPrefix} incrementBadPathCountOrDrop starting with guard ${ed25519Str(guardNodeEd25519)}, reason: "${reason}"` ); const pathWithIssues = onionPaths[pathWithSnodeIndex]; - window?.log?.info('handling bad path for path index', pathWithSnodeIndex); + window?.log?.info( + `${logPrefix} handling bad path for path index${pathWithSnodeIndex}, reason: "${reason}" ` + ); const oldPathFailureCount = pathFailureCount[guardNodeEd25519] || 0; const newPathFailureCount = oldPathFailureCount + 1; @@ -231,11 +282,11 @@ export async function incrementBadPathCountOrDrop(snodeEd25519: string) { // a guard node is dropped when the path is dropped completely (in dropPathStartingWithGuardNode) for (let index = 1; index < pathWithIssues.length; index++) { const snode = pathWithIssues[index]; - await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519: snode.pubkey_ed25519 }); + await Onions.incrementBadSnodeCountOrDrop({ snodeEd25519: snode.pubkey_ed25519, reason }); } if (newPathFailureCount >= pathFailureThreshold) { - return dropPathStartingWithGuardNode(guardNodeEd25519); + return dropPathStartingWithGuardNode(guardNodeEd25519, reason); } // the path is not yet THAT bad. keep it for now pathFailureCount[guardNodeEd25519] = newPathFailureCount; @@ -247,17 +298,19 @@ export async function incrementBadPathCountOrDrop(snodeEd25519: string) { * It writes to the db the updated list of guardNodes. * @param ed25519Key the guard node ed25519 pubkey */ -async function dropPathStartingWithGuardNode(guardNodeEd25519: string) { - await SnodePool.dropSnodeFromSnodePool(guardNodeEd25519); +async function dropPathStartingWithGuardNode(guardNodeEd25519: string, reason: string) { + await SnodePool.dropSnodeFromSnodePool(guardNodeEd25519, reason); const failingPathIndex = onionPaths.findIndex(p => p[0].pubkey_ed25519 === guardNodeEd25519); if (failingPathIndex === -1) { - window?.log?.warn('No such path starts with this guard node '); + window?.log?.warn( + `${logPrefix} No such path starts with this guard node ${ed25519Str(guardNodeEd25519)} ` + ); } else { window?.log?.info( - `Dropping path starting with guard node ${ed25519Str( + `${logPrefix} Dropping path starting with guard node ${ed25519Str( guardNodeEd25519 - )}; index:${failingPathIndex}` + )}; index:${failingPathIndex} with reason: "${reason}"` ); onionPaths = onionPaths.filter(p => p[0].pubkey_ed25519 !== guardNodeEd25519); } @@ -280,7 +333,9 @@ async function internalUpdateGuardNodes(updatedGuardNodes: Array) { } export async function testGuardNode(snode: Snode) { - window?.log?.info(`Testing a candidate guard node ${ed25519Str(snode.pubkey_ed25519)}`); + window?.log?.info( + `${logPrefix} Testing a candidate guard node ${ed25519Str(snode.pubkey_ed25519)}` + ); // Send a post request and make sure it is OK const endpoint = '/storage_rpc/v1'; @@ -314,15 +369,17 @@ export async function testGuardNode(snode: Snode) { try { // Log this line for testing // curl -k -X POST -H 'Content-Type: application/json' -d '"+fetchOptions.body.replace(/"/g, "\\'")+"'", url - window?.log?.info('insecureNodeFetch => plaintext for testGuardNode:', url); + window?.log?.info(`${logPrefix} insecureNodeFetch => plaintext for testGuardNode: ${url}`); response = await insecureNodeFetch(url, fetchOptions); } catch (e) { if (e.type === 'request-timeout') { - window?.log?.warn('testGuardNode request timed out for:', ed25519Str(snode.pubkey_ed25519)); + window?.log?.warn( + `${logPrefix} testGuardNode request timed out for: ${ed25519Str(snode.pubkey_ed25519)}` + ); } if (e.code === 'ENETUNREACH') { - window?.log?.warn('no network on node,', snode); + window?.log?.warn(`${logPrefix} no network on node ${ed25519Str(snode.pubkey_ed25519)}`); throw new pRetry.AbortError(ERROR_CODE_NO_CONNECT); } return false; @@ -330,7 +387,9 @@ export async function testGuardNode(snode: Snode) { if (!response.ok) { await response.text(); - window?.log?.info('Node failed the guard test:', snode); + window?.log?.info( + `${logPrefix} Node failed the guard test: ${ed25519Str(snode.pubkey_ed25519)}` + ); } return response.ok; @@ -345,17 +404,17 @@ export async function selectGuardNodes(): Promise> { // this is to avoid having circular dependencies of path building, needing new snodes, which needs new paths building... const nodePool = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); - window.log.info(`selectGuardNodes snodePool length: ${nodePool.length}`); + window.log.info(`${logPrefix} selectGuardNodes snodePool length: ${nodePool.length}`); if (nodePool.length < SnodePoolConstants.minSnodePoolCount) { window?.log?.error( - `Could not select guard nodes. Not enough nodes in the pool: ${nodePool.length}` + `${logPrefix} Could not select guard nodes. Not enough nodes in the pool: ${nodePool.length}` ); throw new Error( `Could not select guard nodes. Not enough nodes in the pool: ${nodePool.length}` ); } - const shuffled = _.shuffle(nodePool); + const shuffled = shuffle(nodePool); let selectedGuardNodes: Array = []; @@ -375,10 +434,10 @@ export async function selectGuardNodes(): Promise> { if (attempts > 5) { // too many retries. something is wrong. - window.log.info(`selectGuardNodes stopping after attempts: ${attempts}`); + window.log.info(`${logPrefix} selectGuardNodes stopping after attempts: ${attempts}`); throw new Error(`selectGuardNodes stopping after attempts: ${attempts}`); } - window.log.info(`selectGuardNodes attempts: ${attempts}`); + window.log.info(`${logPrefix} selectGuardNodes attempts: ${attempts}`); // Test all three nodes at once, wait for all to resolve or reject // eslint-disable-next-line no-await-in-loop @@ -386,11 +445,11 @@ export async function selectGuardNodes(): Promise> { p => (p.status === 'fulfilled' ? p.value : null) ); - const goodNodes = _.zip(idxOk, candidateNodes) + const goodNodes = zip(idxOk, candidateNodes) .filter(x => x[0]) .map(x => x[1]) as Array; - selectedGuardNodes = _.concat(selectedGuardNodes, goodNodes); + selectedGuardNodes = concat(selectedGuardNodes, goodNodes); attempts++; } @@ -441,14 +500,14 @@ export async function getGuardNodeOrSelectNewOnes() { // if an error is thrown, the caller must take care of it. const start = Date.now(); guardNodes = await OnionPaths.selectGuardNodes(); - window.log.info(`OnionPaths.selectGuardNodes took ${Date.now() - start}ms`); + window.log.info(`${logPrefix} selectGuardNodes took ${Date.now() - start}ms`); } } async function buildNewOnionPathsWorker() { return pRetry( async () => { - window?.log?.info('SessionSnodeAPI::buildNewOnionPaths - building new onion paths...'); + window?.log?.info(`${logPrefix} buildNewOnionPaths - building new onion paths...`); // get an up to date list of snodes from cache, from db, or from the a seed node. let allNodes = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); @@ -463,33 +522,28 @@ async function buildNewOnionPathsWorker() { // be sure to fetch again as that list might have been refreshed by selectGuardNodes allNodes = await SnodePool.getSnodePoolFromDBOrFetchFromSeed(); - window?.log?.info( - `SessionSnodeAPI::buildNewOnionPaths, snodePool length: ${allNodes.length}` - ); + window?.log?.info(`${logPrefix} buildNewOnionPaths, snodePool length: ${allNodes.length}`); // get all snodes minus the selected guardNodes if (allNodes.length <= SnodePoolConstants.minSnodePoolCount) { throw new Error('Too few nodes to build an onion path. Even after fetching from seed.'); } - // make sure to not reuse multiple times the same subnet /24 - const allNodesGroupedBySubnet24 = _.groupBy(allNodes, e => { - const lastDot = e.ip.lastIndexOf('.'); - return e.ip.substr(0, lastDot); - }); - const oneNodeForEachSubnet24KeepingRatio = _.flatten( - _.map(allNodesGroupedBySubnet24, group => { - return _.fill(Array(group.length), _.sample(group) as Snode); - }) + const weightedSingleSnodePerSubnet = SnodePool.getWeightedSingleSnodePerSubnet(allNodes); + + logDebugWithCat( + logPrefix, + ` oneNodeForEachSubnet24KeepingRatio: ${stringify(weightedSingleSnodePerSubnet)}`, + window.sessionFeatureFlags.debugOnionPaths ); - if (oneNodeForEachSubnet24KeepingRatio.length <= SnodePoolConstants.minSnodePoolCount) { + if (weightedSingleSnodePerSubnet.length <= SnodePoolConstants.minSnodePoolCount) { throw new Error( 'Too few nodes "unique by ip" to build an onion path. Even after fetching from seed.' ); } let otherNodes = window.sessionFeatureFlags?.useLocalDevNet ? allNodes - : _.differenceBy(oneNodeForEachSubnet24KeepingRatio, guardNodes, 'pubkey_ed25519'); - const guards = _.shuffle(guardNodes); + : differenceBy(weightedSingleSnodePerSubnet, guardNodes, 'pubkey_ed25519'); + const guards = shuffle(guardNodes); // Create path for every guard node: const nodesNeededPerPaths = ONION_REQUEST_HOPS - 1; @@ -497,7 +551,7 @@ async function buildNewOnionPathsWorker() { // Each path needs nodesNeededPerPaths nodes in addition to the guard node: const maxPath = Math.floor(Math.min(guards.length, otherNodes.length / nodesNeededPerPaths)); window?.log?.info( - `Building ${maxPath} onion paths based on guard nodes length: ${guards.length}, other nodes length ${otherNodes.length} ` + `${logPrefix} Building ${maxPath} onion paths based on guard nodes length: ${guards.length}, other nodes length ${otherNodes.length} ` ); onionPaths = []; @@ -528,8 +582,12 @@ async function buildNewOnionPathsWorker() { onionPaths.push(path); } - window?.log?.info(`Built ${onionPaths.length} onion paths`); - window?.log?.debug(`onionPaths:`, JSON.stringify(onionPaths)); + window?.log?.info(`${logPrefix} Built ${onionPaths.length} onion paths`); + logDebugWithCat( + logPrefix, + ` paths built: ${stringify(onionPaths)}`, + window.sessionFeatureFlags.debugOnionPaths + ); }, { retries: 3, // 4 total diff --git a/ts/session/types/with.ts b/ts/session/types/with.ts index e2503eb3ae..2801a7d634 100644 --- a/ts/session/types/with.ts +++ b/ts/session/types/with.ts @@ -35,3 +35,5 @@ export type WithTargetNode = { targetNode: Snode }; export type WithGuardNode = { guardNode: Snode }; export type WithSymmetricKey = { symmetricKey: ArrayBuffer }; + +export type WithReason = { reason: string }; diff --git a/ts/state/ducks/types/releasedFeaturesReduxTypes.ts b/ts/state/ducks/types/releasedFeaturesReduxTypes.ts index bb9a05d9d4..406478b87d 100644 --- a/ts/state/ducks/types/releasedFeaturesReduxTypes.ts +++ b/ts/state/ducks/types/releasedFeaturesReduxTypes.ts @@ -41,6 +41,8 @@ export type SessionFlags = SessionFeatureFlags & { debugSwarmPolling: boolean; debugServerRequests: boolean; debugNonSnodeRequests: boolean; + debugOnionPaths: boolean; + debugSnodePool: boolean; debugOnionRequests: boolean; }; diff --git a/ts/test/session/unit/crypto/SnodeSignatures_test.ts b/ts/test/session/unit/crypto/SnodeSignatures_test.ts index 469dda2c9d..2966ec023c 100644 --- a/ts/test/session/unit/crypto/SnodeSignatures_test.ts +++ b/ts/test/session/unit/crypto/SnodeSignatures_test.ts @@ -205,22 +205,6 @@ describe('SnodeSignature', () => { await verifySig(ret, verificationData); }); - it.skip('can sign a delete with authData if adminSecretKey is empty', async () => { - // we can't really test this atm. We'd need the full env of wrapper setup as we need need for the subaccountSign itself, part of the wrapper - // const hashes = ['hash4321', 'hash4221']; - // const group = getEmptyUserGroup(); - // const ret = await SnodeGroupSignature.getGroupSignatureByHashesParams({ - // method: 'delete', - // groupPk: validGroupPk, - // messagesHashes: hashes, - // group: { ...group, authData: currentUserSubAccountAuthData }, - // }); - // expect(ret.pubkey).to.be.eq(validGroupPk); - // expect(ret.messages).to.be.deep.eq(hashes); - // const verificationData = `delete${hashes.join('')}`; - // await verifySig(ret, verificationData); - }); - it('throws if none are set', async () => { const hashes = ['hash4321', 'hash4221']; diff --git a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts index 86f12a20e5..83550ce06f 100644 --- a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts +++ b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts @@ -419,51 +419,5 @@ describe('libsession_metagroup', () => { 'rekey should be false on fresh group' ); }); - - it.skip('merging a key conflict marks needsRekey to true', () => { - const metaGroupWrapper2 = new MetaGroupWrapperNode({ - groupEd25519Pubkey: toFixedUint8ArrayOfLength( - HexString.fromHexString(groupCreated.pubkeyHex.slice(2)), - 32 - ).buffer, - groupEd25519Secretkey: groupCreated.secretKey, - metaDumped: null, - userEd25519Secretkey: toFixedUint8ArrayOfLength(us.ed25519KeyPair.privateKey, 64).buffer, - }); - - // mark current user as admin - metaGroupWrapper.memberSetPromotionAccepted(us.x25519KeyPair.pubkeyHex); - metaGroupWrapper2.memberSetPromotionAccepted(us.x25519KeyPair.pubkeyHex); - - // add 2 normal members to each of those wrappers - const m1 = TestUtils.generateFakePubKeyStr(); - const m2 = TestUtils.generateFakePubKeyStr(); - metaGroupWrapper.memberSetInviteAccepted(m1); - metaGroupWrapper.memberSetInviteAccepted(m2); - metaGroupWrapper2.memberSetInviteAccepted(m1); - metaGroupWrapper2.memberSetInviteAccepted(m2); - - expect(metaGroupWrapper.keysNeedsRekey()).to.be.eq(false); - expect(metaGroupWrapper2.keysNeedsRekey()).to.be.eq(false); - - // remove m2 from wrapper2, and m1 from wrapper1 - const rekeyed1 = metaGroupWrapper2.memberEraseAndRekey([m2]); - const rekeyed2 = metaGroupWrapper.memberEraseAndRekey([m1]); - expect(rekeyed1).to.be.eq(true); - expect(rekeyed2).to.be.eq(true); - - // const push1 = metaGroupWrapper.push(); - // metaGroupWrapper2.metaMerge([push1]); - - // const wrapper2Rekeyed = metaGroupWrapper2.keyRekey(); - // metaGroupWrapper.keyRekey(); - - // const loadedKey = metaGroupWrapper.loadKeyMessage('fakehash1', wrapper2Rekeyed, Date.now()); - // expect(loadedKey).to.be.eq(true, 'key should have been loaded'); - expect(metaGroupWrapper.keysNeedsRekey()).to.be.eq( - true, - 'rekey should be true for after add' - ); - }); }); }); diff --git a/ts/test/session/unit/onion/GuardNodes_test.ts b/ts/test/session/unit/onion/GuardNodes_test.ts index 1bb18b1858..d69c29ce44 100644 --- a/ts/test/session/unit/onion/GuardNodes_test.ts +++ b/ts/test/session/unit/onion/GuardNodes_test.ts @@ -11,7 +11,7 @@ import { SeedNodeAPI } from '../../../../session/apis/seed_node_api'; import * as OnionPaths from '../../../../session/onions/onionPath'; import { generateFakeSnodes, - generateFakeSnodeWithEdKey, + generateFakeSnodeWithDetails, stubData, } from '../../../test-utils/utils'; import { Snode } from '../../../../data/types'; @@ -29,9 +29,10 @@ const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f916153 const fakeSnodePool: Array = [ ...generateFakeSnodes(12), - generateFakeSnodeWithEdKey(guard1ed), - generateFakeSnodeWithEdKey(guard2ed), - generateFakeSnodeWithEdKey(guard3ed), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard1ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard2ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard3ed, ip: null }), + ...generateFakeSnodes(3), ]; diff --git a/ts/test/session/unit/onion/OnionErrors_test.ts b/ts/test/session/unit/onion/OnionErrors_test.ts index 463bf59a9f..526df5c853 100644 --- a/ts/test/session/unit/onion/OnionErrors_test.ts +++ b/ts/test/session/unit/onion/OnionErrors_test.ts @@ -20,7 +20,7 @@ import { import { SnodePool } from '../../../../session/apis/snode_api/snodePool'; import { OnionPaths } from '../../../../session/onions'; import { pathFailureCount } from '../../../../session/onions/onionPath'; -import { generateFakeSnodeWithEdKey, stubData } from '../../../test-utils/utils'; +import { generateFakeSnodeWithDetails, stubData } from '../../../test-utils/utils'; chai.use(chaiAsPromised as any); chai.should(); @@ -73,10 +73,14 @@ describe('OnionPathsErrors', () => { SnodeAPI.Onions.resetSnodeFailureCount(); - guardNodesArray = guardPubkeys.map(generateFakeSnodeWithEdKey); + guardNodesArray = guardPubkeys.map(m => + generateFakeSnodeWithDetails({ ed25519Pubkey: m, ip: null }) + ); guardSnode1 = guardNodesArray[0]; - otherNodesArray = otherNodesPubkeys.map(generateFakeSnodeWithEdKey); + otherNodesArray = otherNodesPubkeys.map(m => + generateFakeSnodeWithDetails({ ed25519Pubkey: m, ip: null }) + ); fakeSnodePool = [...guardNodesArray, ...otherNodesArray]; @@ -317,6 +321,7 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, associatedWith, + reason: 'handle421InvalidSwarm', }); }); }); @@ -362,6 +367,7 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, associatedWith, + reason: 'handle421InvalidSwarm', }); }); @@ -400,6 +406,7 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, associatedWith, + reason: 'handle421InvalidSwarm', }); }); @@ -440,6 +447,7 @@ describe('OnionPathsErrors', () => { expect(incrementBadSnodeCountOrDropSpy.firstCall.args[0]).to.deep.eq({ snodeEd25519: targetNode, associatedWith, + reason: 'handle421InvalidSwarm', }); }); }); @@ -650,6 +658,7 @@ describe('OnionPathsErrors', () => { for (let index = 0; index < 6; index++) { expect(incrementBadSnodeCountOrDropSpy.args[index][0]).to.deep.eq({ snodeEd25519: oldOnionPaths[0][(index % 2) + 1].pubkey_ed25519, + reason: 'processAnyOtherErrorOnPath: Otherwise we increment the whole path failure count', }); } diff --git a/ts/test/session/unit/onion/OnionPaths_test.ts b/ts/test/session/unit/onion/OnionPaths_test.ts index aa11667ace..db95254e3e 100644 --- a/ts/test/session/unit/onion/OnionPaths_test.ts +++ b/ts/test/session/unit/onion/OnionPaths_test.ts @@ -1,6 +1,6 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import _ from 'lodash'; +import _, { cloneDeep, range } from 'lodash'; import { describe } from 'mocha'; import Sinon from 'sinon'; @@ -10,7 +10,7 @@ import { TestUtils } from '../../../test-utils'; import { GuardNode, Snode } from '../../../../data/types'; import * as OnionPaths from '../../../../session/onions/onionPath'; import { - generateFakeSnodeWithEdKey, + generateFakeSnodeWithDetails, generateFakeSnodes, stubData, } from '../../../test-utils/utils'; @@ -23,34 +23,38 @@ chai.should(); const { expect } = chai; -const guard1ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534e'; -const guard2ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f91615349'; -const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534a'; - -const fakeSnodePool: Array = [ - ...generateFakeSnodes(12), - generateFakeSnodeWithEdKey(guard1ed), - generateFakeSnodeWithEdKey(guard2ed), - generateFakeSnodeWithEdKey(guard3ed), - ...generateFakeSnodes(9), -]; - -const fakeGuardNodesEd25519 = [guard1ed, guard2ed, guard3ed]; -const fakeGuardNodes = fakeSnodePool.filter(m => fakeGuardNodesEd25519.includes(m.pubkey_ed25519)); -const fakeGuardNodesFromDB: Array = fakeGuardNodesEd25519.map(ed25519PubKey => { - return { - ed25519PubKey, - }; -}); - describe('OnionPaths', () => { // Initialize new stubbed cache let oldOnionPaths: Array>; + const guard1ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534e'; + const guard2ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f91615349'; + const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f9161534a'; + + const fakeSnodePool: Array = [ + ...generateFakeSnodes(12), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard1ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard2ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard3ed, ip: null }), + ...generateFakeSnodes(9), + ]; + + const fakeGuardNodesEd25519 = [guard1ed, guard2ed, guard3ed]; + const fakeGuardNodes = fakeSnodePool.filter(m => + fakeGuardNodesEd25519.includes(m.pubkey_ed25519) + ); + const fakeGuardNodesFromDB: Array = fakeGuardNodesEd25519.map(ed25519PubKey => { + return { + ed25519PubKey, + }; + }); describe('dropSnodeFromPath', () => { beforeEach(async () => { // Utils Stubs OnionPaths.clearTestOnionPath(); + TestUtils.stubWindowLog(); + SNodeAPI.Onions.resetSnodeFailureCount(); + OnionPaths.resetPathFailureCount(); Sinon.stub(OnionPaths, 'selectGuardNodes').resolves(fakeGuardNodes); Sinon.stub(ServiceNodesList, 'getSnodePoolFromSnode').resolves(fakeGuardNodes); @@ -60,27 +64,21 @@ describe('OnionPaths', () => { TestUtils.stubData('createOrUpdateItem').resolves(); TestUtils.stubWindow('getSeedNodeList', () => ['seednode1']); - TestUtils.stubWindowLog(); - Sinon.stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries').resolves(fakeSnodePool); - SNodeAPI.Onions.resetSnodeFailureCount(); - OnionPaths.resetPathFailureCount(); + await OnionPaths.getOnionPath({}); // this triggers a path rebuild // get a copy of what old ones look like - await OnionPaths.getOnionPath({}); oldOnionPaths = OnionPaths.TEST_getTestOnionPath(); if (oldOnionPaths.length !== 3) { throw new Error(`onion path length not enough ${oldOnionPaths.length}`); } - // this just triggers a build of the onionPaths }); - afterEach(() => { - Sinon.restore(); - }); + afterEach(() => Sinon.restore()); + describe('with valid snode pool', () => { it('rebuilds after removing last snode on path', async () => { - await OnionPaths.dropSnodeFromPath(oldOnionPaths[2][2].pubkey_ed25519); + await OnionPaths.dropSnodeFromPath(oldOnionPaths[2][2].pubkey_ed25519, 'unit test'); const newOnionPath = OnionPaths.TEST_getTestOnionPath(); // only the last snode should have been updated @@ -93,21 +91,28 @@ describe('OnionPaths', () => { }); it('rebuilds after removing middle snode on path', async () => { - await OnionPaths.dropSnodeFromPath(oldOnionPaths[2][1].pubkey_ed25519); + // stubWindowLog(); + // stubWindow('sessionFeatureFlags', { debugOnionPaths: true, debugSnodePool: true }); + + const oldOnionPathsCopy = cloneDeep(oldOnionPaths); + + await OnionPaths.dropSnodeFromPath(oldOnionPathsCopy[2][1].pubkey_ed25519, 'unit test'); const newOnionPath = OnionPaths.TEST_getTestOnionPath(); - const allEd25519Keys = _.flattenDeep(oldOnionPaths).map(m => m.pubkey_ed25519); + const oldOnionPath2 = oldOnionPathsCopy[2]; + const allEd25519KeysOldOnionPath2 = _.flattenDeep(oldOnionPath2).map(m => m.pubkey_ed25519); // only the last snode should have been updated - expect(newOnionPath).to.be.not.deep.equal(oldOnionPaths); - expect(newOnionPath[0]).to.be.deep.equal(oldOnionPaths[0]); - expect(newOnionPath[1]).to.be.deep.equal(oldOnionPaths[1]); - expect(newOnionPath[2][0]).to.be.deep.equal(oldOnionPaths[2][0]); + expect(newOnionPath).to.be.not.deep.equal(oldOnionPathsCopy); + expect(newOnionPath[0]).to.be.deep.equal(oldOnionPathsCopy[0]); + expect(newOnionPath[1]).to.be.deep.equal(oldOnionPathsCopy[1]); + expect(newOnionPath[2][0]).to.be.deep.equal(oldOnionPath2[0]); // last item moved to the position one as we removed item 1 and happened one after it - expect(newOnionPath[2][1]).to.be.deep.equal(oldOnionPaths[2][2]); - // the last item we happened must not be any of the new path nodes. + expect(newOnionPath[2][1]).to.be.deep.equal(oldOnionPath2[2]); + // the last item we appended must not be any of the new path nodes. // actually, we remove the nodes causing issues from the snode pool so we shouldn't find this one neither - expect(allEd25519Keys).to.not.include(newOnionPath[2][2].pubkey_ed25519); + + expect(allEd25519KeysOldOnionPath2).to.not.include(newOnionPath[2][2].pubkey_ed25519); }); }); }); @@ -175,5 +180,93 @@ describe('OnionPaths', () => { expect(e.message).to.not.be.eq('fake error'); } }); + + it('throws if we cannot find a node without an ip on the same subnet /24 of one of our path node', async () => { + fetchSnodePoolFromSeedNodeWithRetries.reset(); + // stubWindow('sessionFeatureFlags', { debugOnionPaths: true, debugSnodePool: true }); + + if (OnionPaths.TEST_getTestOnionPath().length) { + throw new Error('expected this to be empty'); + } + fetchSnodePoolFromSeedNodeWithRetries.resolves(fakeSnodePool); + await OnionPaths.getOnionPath({}); + + if (OnionPaths.TEST_getTestOnionPath().length !== 3) { + throw new Error('should have 3 valid onion paths'); + } + const paths = OnionPaths.TEST_getTestOnionPath(); + const snodeToDrop = paths[2][1]; + const otherSnodeInPathOfSnodeDropped = paths[2][2]; + const subnet = otherSnodeInPathOfSnodeDropped.ip.slice( + 0, + otherSnodeInPathOfSnodeDropped.ip.lastIndexOf('.') + ); + // make the snode pool filled with snodes that have the same subnet /24 as the first snode of the path where we dropped a snode. + const badPool = generateFakeSnodes(20).map((m, i) => { + return { ...m, ip: `${subnet}.${50 + i}` }; + }); + fetchSnodePoolFromSeedNodeWithRetries.resolves(badPool); + SnodePool.TEST_resetState(badPool); + // drop a snode from the last path, only allowing snodes with an ip on the same subnet /24 of one of our first node + const func = async () => + OnionPaths.dropSnodeFromPath(snodeToDrop.pubkey_ed25519, 'unit test'); + await expect(func()).rejectedWith('Not enough snodes with snodes to exclude length'); + }); + }); +}); + +describe('OnionPaths selection', () => { + const guardsEd = TestUtils.generateFakePubKeysStr(3); + + const fakeSnodePool: Array = [ + generateFakeSnodeWithDetails({ ed25519Pubkey: guardsEd[0], ip: '127.0.0.54' }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guardsEd[1], ip: '127.0.0.55' }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guardsEd[2], ip: '127.0.0.56' }), + ...range(57, 77).map(lastDigit => + generateFakeSnodeWithDetails({ ed25519Pubkey: null, ip: `127.0.0.${lastDigit}` }) + ), + ]; + + const fakeGuardNodes = fakeSnodePool.filter(m => guardsEd.includes(m.pubkey_ed25519 as string)); + const fakeGuardNodesFromDB: Array = guardsEd.map(ed25519PubKey => { + return { + ed25519PubKey, + }; + }); + + describe('filtering by subnet', () => { + beforeEach(async () => { + OnionPaths.clearTestOnionPath(); + SNodeAPI.Onions.resetSnodeFailureCount(); + OnionPaths.resetPathFailureCount(); + TestUtils.stubWindowLog(); + Sinon.stub(OnionPaths, 'getOnionPathMinTimeout').returns(1); + + Sinon.stub(OnionPaths, 'selectGuardNodes').resolves(fakeGuardNodes); + Sinon.stub(ServiceNodesList, 'getSnodePoolFromSnode').resolves(fakeGuardNodes); + stubData('getSnodePoolFromDb').resolves(fakeSnodePool); + + TestUtils.stubData('getGuardNodes').resolves(fakeGuardNodesFromDB); + TestUtils.stubData('createOrUpdateItem').resolves(); + TestUtils.stubWindow('getSeedNodeList', () => ['seednode1']); + + Sinon.stub(SeedNodeAPI, 'fetchSnodePoolFromSeedNodeWithRetries').resolves(fakeSnodePool); + }); + + afterEach(() => { + Sinon.restore(); + }); + + it('throws if we cannot build a path filtering with /24 subnet', async () => { + TestUtils.stubWindowLog(); + const onStartOnionPaths = OnionPaths.TEST_getTestOnionPath(); + expect(onStartOnionPaths.length).to.eq(0); + + // generate a new set of path, this should fail + const func = () => OnionPaths.getOnionPath({}); + await expect(func()).to.be.rejectedWith( + 'Failed to build enough onion paths, current count: 0' + ); + }); }); }); diff --git a/ts/test/session/unit/onion/SeedNodeAPI_test.ts b/ts/test/session/unit/onion/SeedNodeAPI_test.ts index 4f2ef10be6..a2c5da5ca5 100644 --- a/ts/test/session/unit/onion/SeedNodeAPI_test.ts +++ b/ts/test/session/unit/onion/SeedNodeAPI_test.ts @@ -12,7 +12,7 @@ import { Snode } from '../../../../data/types'; import { SeedNodeAPI } from '../../../../session/apis/seed_node_api'; import { SnodeFromSeed } from '../../../../session/apis/seed_node_api/SeedNodeAPI'; import * as OnionPaths from '../../../../session/onions/onionPath'; -import { generateFakeSnodes, generateFakeSnodeWithEdKey } from '../../../test-utils/utils'; +import { generateFakeSnodes, generateFakeSnodeWithDetails } from '../../../test-utils/utils'; chai.use(chaiAsPromised as any); chai.should(); @@ -25,9 +25,9 @@ const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f916153 const fakeSnodePool: Array = [ ...generateFakeSnodes(12), - generateFakeSnodeWithEdKey(guard1ed), - generateFakeSnodeWithEdKey(guard2ed), - generateFakeSnodeWithEdKey(guard3ed), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard1ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard2ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard3ed, ip: null }), ...generateFakeSnodes(3), ]; diff --git a/ts/test/session/unit/onion/SnodePoolUpdate_test.ts b/ts/test/session/unit/onion/SnodePoolUpdate_test.ts index fbca7711bd..4fb93f6c49 100644 --- a/ts/test/session/unit/onion/SnodePoolUpdate_test.ts +++ b/ts/test/session/unit/onion/SnodePoolUpdate_test.ts @@ -13,7 +13,7 @@ import { Snode } from '../../../../data/types'; import * as OnionPaths from '../../../../session/onions/onionPath'; import { generateFakeSnodes, - generateFakeSnodeWithEdKey, + generateFakeSnodeWithDetails, stubData, } from '../../../test-utils/utils'; @@ -28,9 +28,9 @@ const guard3ed = 'e3ec6fcc79e64c2af6a48a9865d4bf4b739ec7708d75f35acc3d478f916153 const fakeSnodePool: Array = [ ...generateFakeSnodes(12), - generateFakeSnodeWithEdKey(guard1ed), - generateFakeSnodeWithEdKey(guard2ed), - generateFakeSnodeWithEdKey(guard3ed), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard1ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard2ed, ip: null }), + generateFakeSnodeWithDetails({ ed25519Pubkey: guard3ed, ip: null }), ...generateFakeSnodes(3), ]; diff --git a/ts/test/session/unit/updater/updater_test.ts b/ts/test/session/unit/updater/updater_test.ts index baaf814173..98d31f6fd2 100644 --- a/ts/test/session/unit/updater/updater_test.ts +++ b/ts/test/session/unit/updater/updater_test.ts @@ -6,8 +6,6 @@ import { expect } from 'chai'; import { enableLogRedirect } from '../../../test-utils/utils'; describe('Updater', () => { - it.skip('isUpdateAvailable', () => {}); - it('package.json target is correct', () => { const content = readFileSync( path.join(__dirname, '..', '..', '..', '..', '..', 'package.json') diff --git a/ts/test/test-utils/utils/pubkey.ts b/ts/test/test-utils/utils/pubkey.ts index 6fd3b6e01c..f6e92ad965 100644 --- a/ts/test/test-utils/utils/pubkey.ts +++ b/ts/test/test-utils/utils/pubkey.ts @@ -1,7 +1,7 @@ import * as crypto from 'crypto'; import { GroupPubkeyType, PubkeyType, UserGroupsWrapperNode } from 'libsession_util_nodejs'; import { KeyPair, to_hex } from 'libsodium-wrappers-sumo'; -import _ from 'lodash'; +import _, { range } from 'lodash'; import { Snode } from '../../../data/types'; import { getSodiumNode } from '../../../node/sodiumNode'; import { SnodePool } from '../../../session/apis/snode_api/snodePool'; @@ -27,6 +27,10 @@ export function generateFakePubKeyStr(): PubkeyType { return pubkeyString; } +export function generateFakePubKeysStr(amount: number): Array { + return range(0, amount).map(generateFakePubKeyStr); +} + export type TestUserKeyPairs = { x25519KeyPair: { pubkeyHex: PubkeyType; @@ -105,13 +109,21 @@ function ipv4Section() { return Math.floor(Math.random() * 255); } -export function generateFakeSnodeWithEdKey(ed25519Pubkey: string): Snode { +export function generateFakeSnodeWithDetails({ + ed25519Pubkey, + ip, +}: { + ed25519Pubkey: string | null; + ip: string | null; +}): Snode { return { // NOTE: make sure this is random, but not a valid ip (otherwise we will try to hit that ip during testing!) - ip: `${ipv4Section()}.${ipv4Section()}.${ipv4Section()}.${ipv4Section()}.${ipv4Section()}.${ipv4Section()}`, + ip: + ip ?? + `${ipv4Section()}.${ipv4Section()}.${ipv4Section()}.${ipv4Section()}.${ipv4Section()}.${ipv4Section()}`, port: 22116, pubkey_x25519: generateFakePubKeyStr(), - pubkey_ed25519: ed25519Pubkey, + pubkey_ed25519: ed25519Pubkey ?? generateFakePubKeyStr(), storage_server_version: [2, 8, 0], }; } diff --git a/ts/test/test-utils/utils/stubbing.ts b/ts/test/test-utils/utils/stubbing.ts index aa72517c41..697e474e26 100644 --- a/ts/test/test-utils/utils/stubbing.ts +++ b/ts/test/test-utils/utils/stubbing.ts @@ -124,7 +124,7 @@ export function stubSVGElement() { } } -export const enableLogRedirect = false; +export const enableLogRedirect = !!process.env.TEST_LOG_REDIRECT; export const stubWindowLog = () => { stubWindow('log', { diff --git a/ts/util/logger/debugLog.ts b/ts/util/logger/debugLog.ts new file mode 100644 index 0000000000..942132b4d7 --- /dev/null +++ b/ts/util/logger/debugLog.ts @@ -0,0 +1,11 @@ +export function logDebugWithCat( + cat: `[${string}]`, + message: string, + enable: boolean, + ...args: Array +) { + if (!enable) { + return; + } + window?.log?.debug(`${cat} ${message}`, ...args); +}