diff --git a/packages/cli/src/tunnel-server-client.ts b/packages/cli/src/tunnel-server-client.ts index 4f0aa397..a4bdbdbe 100644 --- a/packages/cli/src/tunnel-server-client.ts +++ b/packages/cli/src/tunnel-server-client.ts @@ -33,7 +33,7 @@ export const connectToTunnelServerSsh = async ({ tunnelOpts, log, tunnelingKey, log, tunnelOpts, clientPrivateKey: tunnelingKey, - username: process.env.USER || 'preview', + username: process.env.USER || 'preevy', confirmHostFingerprint: async (...args) => { spinner?.stop() return await confirmHostFingerprint(...args) diff --git a/packages/common/src/ssh/base-client.ts b/packages/common/src/ssh/base-client.ts index 2ed7ab3a..1d9b1027 100644 --- a/packages/common/src/ssh/base-client.ts +++ b/packages/common/src/ssh/base-client.ts @@ -140,6 +140,7 @@ export const baseSshClient = async ( ssh.on('ready', () => resolve(result)) ssh.on('error', err => { reject(err) + ssh.end() }) ssh.connect({ debug: msg => log.debug(msg), diff --git a/packages/compose-tunnel-agent/index.ts b/packages/compose-tunnel-agent/index.ts index 23e09fce..e152405e 100644 --- a/packages/compose-tunnel-agent/index.ts +++ b/packages/compose-tunnel-agent/index.ts @@ -95,11 +95,6 @@ const main = async () => { log: sshLog, }) - sshClient.ssh.on('error', async err => { - log.error('ssh client error: %j', inspect(err)) - await sshClient.end() - }) - sshClient.ssh.on('close', () => { if (!endRequested) { log.error('ssh client closed unexpectedly') @@ -147,9 +142,10 @@ const SHUTDOWN_TIMEOUT = 5000 void main().then( ({ end }) => { - ['SIGTERM', 'SIGINT'].forEach(signal => { - process.once(signal, async () => { - log.info(`shutting down on ${signal}`) + ['SIGTERM', 'SIGINT', 'uncaughtException'].forEach(signal => { + process.once(signal, async (...args) => { + const argsStr = args ? args.map(arg => inspect(arg)).join(', ') : undefined + log.warn(`shutting down on ${[signal, argsStr].filter(Boolean).join(': ')}`) const endResult = await Promise.race([ end().then(() => true), new Promise(resolve => { setTimeout(resolve, SHUTDOWN_TIMEOUT) }), @@ -157,7 +153,7 @@ void main().then( if (!endResult) { log.error(`timed out while waiting ${SHUTDOWN_TIMEOUT}ms for server to close, exiting`) } - process.exit(0) + process.exit(1) }) }) }, diff --git a/packages/compose-tunnel-agent/src/ssh/tunnel-client.ts b/packages/compose-tunnel-agent/src/ssh/tunnel-client.ts index 582cca09..dc0f34d3 100644 --- a/packages/compose-tunnel-agent/src/ssh/tunnel-client.ts +++ b/packages/compose-tunnel-agent/src/ssh/tunnel-client.ts @@ -42,6 +42,11 @@ export const sshClient = async ({ connectionConfig, }) + ssh.on('error', err => { + log.error('ssh client error: %j', inspect(err)) + // baseSshClient calls end + }) + const currentForwards = new Map() ssh.on('unix connection', ({ socketPath: forwardRequestId }, accept, reject) => { diff --git a/tunnel-server/index.ts b/tunnel-server/index.ts index f2b4cc21..e5aa2a78 100644 --- a/tunnel-server/index.ts +++ b/tunnel-server/index.ts @@ -45,17 +45,17 @@ const saasPublicKey = createPublicKey(SAAS_PUBLIC_KEY) const SAAS_JWT_ISSUER = process.env.SAAS_JWT_ISSUER ?? 'app.livecycle.run' const activeTunnelStore = inMemoryActiveTunnelStore({ log }) -const appSessionStore = cookieSessionStore({ domain: BASE_URL.hostname, schema: claimsSchema, keys: process.env.COOKIE_SECRETS?.split(' ') }) +const sessionStore = cookieSessionStore({ domain: BASE_URL.hostname, schema: claimsSchema, keys: process.env.COOKIE_SECRETS?.split(' ') }) const loginUrl = new URL('/login', editUrl(BASE_URL, { hostname: `auth.${BASE_URL.hostname}` })).toString() const app = createApp({ - sessionStore: appSessionStore, + sessionStore, activeTunnelStore, baseUrl: BASE_URL, proxy: proxy({ activeTunnelStore, log, loginUrl, - sessionStore: appSessionStore, + sessionStore, saasPublicKey, jwtSaasIssuer: SAAS_JWT_ISSUER, baseHostname: BASE_URL.hostname, @@ -65,7 +65,6 @@ const app = createApp({ jwtSaasIssuer: SAAS_JWT_ISSUER, saasPublicKey, }) -const sshLogger = log.child({ name: 'ssh_server' }) const tunnelUrl = ( rootUrl: URL, @@ -74,7 +73,7 @@ const tunnelUrl = ( ) => editUrl(rootUrl, { hostname: `${activeTunnelStoreKey(clientId, tunnel)}.${rootUrl.hostname}` }).toString() const sshServer = createSshServer({ - log: sshLogger, + log: log.child({ name: 'ssh_server' }), sshPrivateKey, socketDir: '/tmp', // TODO activeTunnelStore, diff --git a/tunnel-server/jest.config.cjs b/tunnel-server/jest.config.cjs index 1fd541c2..f64e6b81 100644 --- a/tunnel-server/jest.config.cjs +++ b/tunnel-server/jest.config.cjs @@ -1,6 +1,17 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { - preset: 'ts-jest', + preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', testMatch: ['!dist/', '**/*.test.ts'], -}; \ No newline at end of file + extensionsToTreatAsEsm: ['.ts'], + transform: { + // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` + // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, +} diff --git a/tunnel-server/package.json b/tunnel-server/package.json index 0dbe3e63..981e96a4 100644 --- a/tunnel-server/package.json +++ b/tunnel-server/package.json @@ -14,14 +14,20 @@ "iconv-lite": "^0.6.3", "jose": "^4.14.4", "lodash": "^4.17.21", + "node-fetch": "2.6.9", + "p-timeout": "^6.1.2", "pino": "^8.11.0", "pino-pretty": "^9.4.0", "prom-client": "^14.2.0", "ssh2": "^1.12.0", "ts-node": "^10.9.1", "ts-pattern": "^5.0.4", + "tseep": "^1.1.1", "zod": "^3.21.4" }, + "engines": { + "node": ">=18.0.0" + }, "devDependencies": { "@jest/globals": "^29.5.0", "@types/content-type": "^1.1.5", @@ -29,6 +35,7 @@ "@types/http-proxy": "^1.17.9", "@types/lodash": "^4.14.192", "@types/node": "18", + "@types/node-fetch": "^2.6.4", "@types/ssh2": "^1.11.8", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", @@ -40,7 +47,7 @@ "wait-for-expect": "^3.0.2" }, "scripts": { - "test": "yarn jest", + "test": "yarn node --experimental-vm-modules $(yarn bin jest)", "start": "ts-node ./index.ts", "build": "tsc --noEmit", "dev": "DEBUG=1 yarn nodemon ./index.ts", diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts index 7ec89d46..60eef080 100644 --- a/tunnel-server/src/app.ts +++ b/tunnel-server/src/app.ts @@ -73,11 +73,12 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi res.statusCode = 400 return { error: 'returnPath must be a relative path' } } - const activeTunnel = await activeTunnelStore.get(envId) - if (!activeTunnel) { + const activeTunnelEntry = await activeTunnelStore.get(envId) + if (!activeTunnelEntry) { res.statusCode = 404 return { error: 'unknown envId' } } + const { value: activeTunnel } = activeTunnelEntry const session = sessionStore(req.raw, res.raw, activeTunnel.publicKeyThumbprint) if (!session.user) { const auth = jwtAuthenticator( diff --git a/tunnel-server/src/events.test.ts b/tunnel-server/src/events.test.ts new file mode 100644 index 00000000..f9d040b1 --- /dev/null +++ b/tunnel-server/src/events.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from 'tseep' +import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals' +import { TimeoutError } from 'p-timeout' +import { onceWithTimeout } from './events' + +describe('onceWithTimeout', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + afterAll(() => { + jest.useRealTimers() + }) + + let emitter: EventEmitter<{ foo: () => void; error: (err: Error) => void }> + beforeEach(() => { + emitter = new EventEmitter() + }) + + describe('when no timeout occurs', () => { + let p: Promise + beforeEach(() => { + p = onceWithTimeout(emitter, 'foo', { milliseconds: 10 }) + emitter.emit('foo') + }) + it('resolves to undefined', async () => { + await expect(p).resolves.toBeUndefined() + }) + }) + + describe('when an error is emitted', () => { + let p: Promise + const e = new Error('boom') + beforeEach(() => { + p = onceWithTimeout(emitter, 'foo', { milliseconds: 10 }) + emitter.emit('error', e) + }) + it('rejects with the error', async () => { + await expect(p).rejects.toThrow(e) + }) + }) + + describe('when a timeout occurs', () => { + describe('when no fallback is specified', () => { + let p: Promise + beforeEach(() => { + p = onceWithTimeout(emitter, 'foo', { milliseconds: 10 }) + jest.advanceTimersByTime(10) + }) + + it('rejects with a TimeoutError', async () => { + await expect(p).rejects.toThrow(TimeoutError) + await expect(p).rejects.toThrow('timed out after 10ms') + }) + }) + + describe('when a fallback is specified', () => { + let p: Promise<12> + beforeEach(() => { + p = onceWithTimeout(emitter, 'foo', { milliseconds: 10, fallback: async () => 12 as const }) + jest.advanceTimersByTime(10) + }) + + it('resolves with the fallback', async () => { + await expect(p).resolves.toBe(12) + }) + }) + }) +}) diff --git a/tunnel-server/src/events.ts b/tunnel-server/src/events.ts new file mode 100644 index 00000000..d801cbb9 --- /dev/null +++ b/tunnel-server/src/events.ts @@ -0,0 +1,36 @@ +import events from 'events' +import { TimeoutError } from 'p-timeout' + +interface NodeEventTarget { + once(eventName: string | symbol, listener: (...args: unknown[]) => void): this +} + +export async function onceWithTimeout( + target: NodeEventTarget, + event: string | symbol, + opts: { milliseconds: number }, +): Promise +export async function onceWithTimeout ( + target: NodeEventTarget, + event: string | symbol, + opts: { milliseconds: number; fallback: () => T | Promise }, +): Promise +export async function onceWithTimeout ( + target: NodeEventTarget, + event: string | symbol, + { milliseconds, fallback }: { milliseconds: number; fallback?: () => T | Promise }, +): Promise { + const signal = AbortSignal.timeout(milliseconds) + return await events.once(target, event, { signal }).then( + () => undefined, + async e => { + if (!signal.aborted || (e as Error).name !== 'AbortError') { + throw e + } + if (fallback) { + return await fallback() + } + throw new TimeoutError(`timed out after ${milliseconds}ms`) + }, + ) +} diff --git a/tunnel-server/src/id-generator.ts b/tunnel-server/src/id-generator.ts new file mode 100644 index 00000000..15a66c22 --- /dev/null +++ b/tunnel-server/src/id-generator.ts @@ -0,0 +1,10 @@ +export const idGenerator = () => { + let nextId = 0 + return { + next: () => { + const result = nextId + nextId += 1 + return result + }, + } +} diff --git a/tunnel-server/src/memory-store.ts b/tunnel-server/src/memory-store.ts new file mode 100644 index 00000000..3d2fdb99 --- /dev/null +++ b/tunnel-server/src/memory-store.ts @@ -0,0 +1,53 @@ +import { Logger } from 'pino' +import { IEventEmitter, EventEmitter } from 'tseep' +import { nextTick } from 'process' +import { idGenerator } from './id-generator' + +export class KeyAlreadyExistsError extends Error { + constructor(readonly key: string, readonly value: V) { + super(`key already exists: "${key}"`) + } +} + +export type TransactionDescriptor = { readonly txId: number | string } + +type StoreEvents = { + delete: () => void +} + +export type EntryWatcher = { + once: (event: 'delete', listener: () => void) => void +} + +export const inMemoryStore = ({ log }: { log: Logger }) => { + type MapValue = { value: V; watcher: IEventEmitter; setTx: TransactionDescriptor } + const map = new Map() + const txIdGen = idGenerator() + return { + get: async (key: string) => { + const entry = map.get(key) + return entry === undefined ? undefined : { value: entry.value, watcher: entry.watcher } + }, + set: async (key: string, value: V) => { + const existing = map.get(key) + if (existing !== undefined) { + throw new KeyAlreadyExistsError(key, existing.value) + } + const tx: TransactionDescriptor = { txId: txIdGen.next() } + log.debug('setting key %s id %s: %j', key, tx.txId, value) + const watcher = new EventEmitter() + map.set(key, { value, watcher, setTx: tx }) + return { tx, watcher: watcher as EntryWatcher } + }, + delete: async (key: string, setTx?: TransactionDescriptor) => { + const value = map.get(key) + if (value && (setTx === undefined || value.setTx.txId === setTx.txId) && map.delete(key)) { + nextTick(() => { value.watcher.emit('delete') }) + return true + } + return false + }, + } +} + +export type Store = ReturnType> diff --git a/tunnel-server/src/tunnel-store/array-map.test.ts b/tunnel-server/src/multimap.test.ts similarity index 65% rename from tunnel-server/src/tunnel-store/array-map.test.ts rename to tunnel-server/src/multimap.test.ts index 18ad284c..8e1a2f84 100644 --- a/tunnel-server/src/tunnel-store/array-map.test.ts +++ b/tunnel-server/src/multimap.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, beforeEach } from '@jest/globals' -import { MultiMap, multimap } from './array-map' +import { MultiMap, multimap } from './multimap' describe('multimap', () => { - let a: MultiMap + type ObjType = { x: number } + let a: MultiMap const expectedValues = [{ x: 12 }, { x: 13 }] as const beforeEach(() => { a = multimap() @@ -17,7 +18,7 @@ describe('multimap', () => { }) describe('when the key exists', () => { - let values: readonly { x: number }[] | undefined + let values: readonly ObjType[] | undefined beforeEach(() => { values = a.get('foo') }) @@ -28,11 +29,26 @@ describe('multimap', () => { expect(values).toContain(expectedValues[1]) }) + describe('when the returned array is mutated', () => { + beforeEach(() => { + (values as ObjType[]).push({ x: 14 }) + }) + it('does not affect the multimap', () => { + expect(a.get('foo')).toHaveLength(2) + }) + }) + describe('when delete is called with a predicate that returns false for everything', () => { + let deleteReturn: boolean beforeEach(() => { - a.delete('foo', () => false) + deleteReturn = a.delete('foo', () => false) values = a.get('foo') }) + + it('returns false', () => { + expect(deleteReturn).toBe(false) + }) + it('does not delete the values', () => { expect(values).toBeDefined() expect(values).toHaveLength(2) @@ -42,21 +58,32 @@ describe('multimap', () => { }) describe('when delete is called with a predicate that returns true for everything', () => { + let deleteReturn: boolean beforeEach(() => { - a.delete('foo', () => true) + deleteReturn = a.delete('foo', () => true) values = a.get('foo') }) + + it('returns true', () => { + expect(deleteReturn).toBe(true) + }) + it('deletes the values', () => { expect(values).toBeUndefined() }) }) describe('when delete is called with a predicate that returns true for a specific value', () => { + let deleteReturn: boolean beforeEach(() => { - a.delete('foo', ({ x }) => x === expectedValues[0].x) + deleteReturn = a.delete('foo', ({ x }) => x === expectedValues[0].x) values = a.get('foo') }) + it('returns true', () => { + expect(deleteReturn).toBe(true) + }) + it('deletes the specific value', () => { expect(values).toBeDefined() expect(values).toHaveLength(1) diff --git a/tunnel-server/src/tunnel-store/array-map.ts b/tunnel-server/src/multimap.ts similarity index 72% rename from tunnel-server/src/tunnel-store/array-map.ts rename to tunnel-server/src/multimap.ts index 40f57e6d..3c01d68a 100644 --- a/tunnel-server/src/tunnel-store/array-map.ts +++ b/tunnel-server/src/multimap.ts @@ -1,13 +1,13 @@ export type MultiMap = { get: (key: K) => readonly V[] | undefined add: (key: K, value: V) => void - delete: (key: K, pred: (value: V) => boolean) => void + delete: (key: K, pred: (value: V) => boolean) => boolean } export const multimap = (): MultiMap => { const map = new Map() return { - get: (key: K) => map.get(key), + get: (key: K) => map.get(key)?.slice(), add: (key: K, value: V) => { let ar = map.get(key) if (ar === undefined) { @@ -19,15 +19,19 @@ export const multimap = (): MultiMap => { delete: (key: K, pred: (value: V) => boolean) => { let ar = map.get(key) if (ar === undefined) { - return undefined + return false } + const prevLength = ar.length ar = ar.filter(value => !pred(value)) + if (prevLength === ar.length) { + return false + } if (ar.length === 0) { map.delete(key) } else { map.set(key, ar) } - return undefined + return true }, } } diff --git a/tunnel-server/src/proxy/router.ts b/tunnel-server/src/proxy/router.ts index 3587f42d..fa2031af 100644 --- a/tunnel-server/src/proxy/router.ts +++ b/tunnel-server/src/proxy/router.ts @@ -44,7 +44,7 @@ export const proxyRouter = ( return async activeTunnelStore => { const activeTunnel = await activeTunnelStore.get(parsed.firstLabel) return activeTunnel - ? { path: url as string, activeTunnel } + ? { path: url as string, activeTunnel: activeTunnel.value } : undefined } } @@ -66,7 +66,7 @@ export const proxyRouter = ( return async activeTunnelStore => { const activeTunnel = await activeTunnelStore.get(tunnel) return activeTunnel - ? { path: path as string, activeTunnel } + ? { path: path as string, activeTunnel: activeTunnel.value } : undefined } } diff --git a/tunnel-server/src/ssh/base-server.ts b/tunnel-server/src/ssh/base-server.ts index 4703cc25..70918b88 100644 --- a/tunnel-server/src/ssh/base-server.ts +++ b/tunnel-server/src/ssh/base-server.ts @@ -1,12 +1,15 @@ -import crypto, { randomBytes } from 'crypto' +import crypto, { createPublicKey, randomBytes } from 'crypto' import { FastifyBaseLogger } from 'fastify/types/logger' import net from 'net' import path from 'path' -import ssh2, { ParsedKey, SocketBindInfo } from 'ssh2' +import events from 'events' +import ssh2, { SocketBindInfo } from 'ssh2' import { inspect } from 'util' -import EventEmitter from 'node:events' +import { EventEmitter, IEventEmitter } from 'tseep' +import { calculateJwkThumbprintUri, exportJWK } from 'jose' import { ForwardRequest, parseForwardRequest } from '../forward-request' import { createDestroy } from '../destroy-server' +import { onceWithTimeout } from '../events' const clientIdFromPublicSsh = (key: Buffer) => crypto.createHash('sha1').update(key).digest('base64url').replace(/[_-]/g, '') @@ -28,65 +31,52 @@ const parseForwardRequestFromSocketBindInfo = ( } } -export interface ClientForward extends EventEmitter { - on: ( - (event: 'close', listener: () => void) => this - ) & ( - (event: 'error', listener: (err: Error) => void) => this - ) +export type ClientForward = IEventEmitter<{ + close: () => void + error: (err: Error) => void +}> + +export type BaseSshClientEvents = { + forward: ( + requestId: string, + request: ForwardRequest, + localSocketPath: string, + accept: () => Promise, + reject: (reason: Error) => void, + ) => void + exec: ( + command: string, + respondWithJson: (content: unknown) => void, + reject: () => void, + ) => void + error: (err: Error) => void + end: () => void } -export interface BaseSshClient extends EventEmitter { +export interface BaseSshClient extends IEventEmitter { envId: string clientId: string - publicKey: ParsedKey - on: ( - ( - event: 'forward', - listener: ( - requestId: string, - request: ForwardRequest, - localSocketPath: string, - accept: () => Promise, - reject: (reason: Error) => void, - ) => void - ) => this - ) & ( - ( - event: 'exec', - listener: ( - command: string, - respondWithJson: (content: unknown) => void, - reject: () => void, - ) => void - ) => this - ) & ( - ( - event: 'error', - listener: (err: Error) => void, - ) => this - ) + publicKey: crypto.KeyObject + publicKeyThumbprint: string + end: () => Promise + ping: (timeoutMs: number) => Promise + connectionId: string + log: FastifyBaseLogger +} + +type BaseSshServerEvents = { + client: (client: BaseSshClient) => void + error: (err: Error) => void } -export interface BaseSshServer extends EventEmitter { +export interface BaseSshServer extends IEventEmitter { close: ssh2.Server['close'] listen: ssh2.Server['listen'] - on: ( - ( - event: 'client', - listener: (client: BaseSshClient) => void, - ) => this - ) & ( - ( - event: 'error', - listener: (err: Error) => void, - ) => this - ) } export const baseSshServer = ( { - log, + log: serverLog, sshPrivateKey, socketDir, }: { @@ -95,7 +85,7 @@ export const baseSshServer = ( socketDir: string } ): BaseSshServer => { - const serverEmitter = new EventEmitter({ captureRejections: true }) as Omit + const serverEmitter = new EventEmitter() const server = new ssh2.Server( { // debug: x => log.debug(x), @@ -105,10 +95,28 @@ export const baseSshServer = ( }, client => { let preevySshClient: BaseSshClient + const connectionId = `ssh-client-${Math.random().toString(36).substring(2, 9)}` + let log = serverLog.child({ connectionId }) const socketServers = new Map() + let ended = false + const end = async () => { + if (!ended) { + client.end() + await events.once(client, 'end') + } + } + + let authContext: ssh2.AuthContext + let key: ssh2.ParsedKey + + const ping = async (milliseconds: number) => { + const result = onceWithTimeout(client, 'rekey', { milliseconds, fallback: () => 'timeout' as const }) + client.rekey() + return await result !== 'timeout' + } client - .on('authentication', ctx => { + .on('authentication', async ctx => { log.debug('authentication: %j', ctx) if (ctx.method !== 'publickey') { ctx.reject(['publickey']) @@ -130,13 +138,28 @@ export const baseSshServer = ( return } - preevySshClient = Object.assign(new EventEmitter({ captureRejections: true }), { - publicKey: keyOrError, - clientId: clientIdFromPublicSsh(keyOrError.getPublicSSH()), - envId: ctx.username, - }) - log.debug('accepting clientId %j envId %j', preevySshClient.clientId, preevySshClient.envId) + authContext = ctx + key = keyOrError + log.debug('accepting connection') ctx.accept() + }) + .on('ready', async () => { + const publicKey = createPublicKey(key.getPublicPEM()) + const envId = authContext.username + const clientId = clientIdFromPublicSsh(key.getPublicSSH()) + log = serverLog.child({ clientId, envId, connectionId }) + + preevySshClient = Object.assign(new EventEmitter(), { + connectionId, + clientId, + envId, + publicKey, + publicKeyThumbprint: await calculateJwkThumbprintUri(await exportJWK(publicKey)), + end, + ping, + log, + }) + serverEmitter.emit('client', preevySshClient) }) .on('request', async (accept, reject, name, info) => { @@ -214,7 +237,7 @@ export const baseSshServer = ( log.debug('streamlocal-forward@openssh.com: request %j calling accept: %j', request, accept) accept?.() socketServers.set(request, socketServer) - resolveForward(socketServer) + resolveForward(socketServer as ClientForward) }) .on('error', (err: unknown) => { log.error('socketServer request %j error: %j', request, err) @@ -241,6 +264,11 @@ export const baseSshServer = ( preevySshClient?.emit('error', err) client.end() }) + .once('end', () => { + log.debug('client end') + ended = true + preevySshClient?.emit('end') + }) .on('session', accept => { log.debug('session') const session = accept() @@ -266,7 +294,7 @@ export const baseSshServer = ( } ) .on('error', (err: unknown) => { - log.error('ssh server error: %j', err) + serverLog.error('ssh server error: %j', err) }) return Object.assign(serverEmitter, { diff --git a/tunnel-server/src/ssh/index.ts b/tunnel-server/src/ssh/index.ts index 727d1c51..a1371ead 100644 --- a/tunnel-server/src/ssh/index.ts +++ b/tunnel-server/src/ssh/index.ts @@ -1,13 +1,13 @@ import { Logger } from 'pino' -import { createPublicKey } from 'crypto' -import { calculateJwkThumbprintUri, exportJWK } from 'jose' import { inspect } from 'util' import { Gauge } from 'prom-client' -import { baseSshServer } from './base-server' -import { ActiveTunnelStore, KeyAlreadyExistsError, activeTunnelStoreKey } from '../tunnel-store' +import { BaseSshClient, baseSshServer } from './base-server' +import { ActiveTunnelStore, activeTunnelStoreKey } from '../tunnel-store' +import { KeyAlreadyExistsError } from '../memory-store' +import { onceWithTimeout } from '../events' export const createSshServer = ({ - log, + log: serverLog, sshPrivateKey, socketDir, activeTunnelStore, @@ -22,41 +22,54 @@ export const createSshServer = ({ tunnelUrl: (clientId: string, remotePath: string) => string helloBaseResponse: Record tunnelsGauge: Pick -}) => baseSshServer({ - log, - sshPrivateKey, - socketDir, -}) - .on('client', client => { - const { clientId, publicKey, envId } = client - const pk = createPublicKey(publicKey.getPublicPEM()) +}) => { + const storeKeyToClient = new Map() + const onClient = (client: BaseSshClient) => { + const { clientId, publicKey, envId, connectionId, publicKeyThumbprint, log } = client const tunnels = new Map() - const jwkThumbprint = (async () => await calculateJwkThumbprintUri(await exportJWK(pk)))() client .on('forward', async (requestId, { path: tunnelPath, access, meta, inject }, localSocketPath, accept, reject) => { const key = activeTunnelStoreKey(clientId, tunnelPath) + log.info('creating tunnel %s for localSocket %s', key, localSocketPath) - const setTx = await activeTunnelStore.set(key, { + const set = async (): ReturnType => await activeTunnelStore.set(key, { tunnelPath, envId, target: localSocketPath, clientId, - publicKey: pk, + publicKey, access, hostname: key, - publicKeyThumbprint: await jwkThumbprint, + publicKeyThumbprint, meta, inject, - }).catch(e => { - reject( - e instanceof KeyAlreadyExistsError - ? new Error(`duplicate path: ${key}, client map contains path: ${tunnels.has(key)}`) - : new Error(`error setting tunnel ${key}: ${e}`, { cause: e }), - ) + client, + }).catch(async e => { + if (!(e instanceof KeyAlreadyExistsError)) { + throw e + } + const existingEntry = await activeTunnelStore.get(key) + if (!existingEntry) { + return await set() // retry + } + const otherClient = existingEntry.value.client as BaseSshClient + if (otherClient.connectionId === connectionId) { + throw new Error(`duplicate path: ${key}, from same connection ${connectionId}`) + } + if (!await otherClient.ping(5000)) { + const existingDelete = onceWithTimeout(existingEntry.watcher, 'delete', { milliseconds: 2000 }) + void otherClient.end() + await existingDelete + return await set() // retry + } + throw new Error(`duplicate path: ${key}, from different connection ${connectionId}`) }) - if (!setTx) { + + const setResult = await set().catch(err => { reject(err) }) + if (!setResult) { return undefined } + const { tx: setTx } = setResult const forward = await accept().catch(async e => { log.warn('error accepting forward %j: %j', requestId, inspect(e)) await activeTunnelStore.delete(key, setTx) @@ -64,6 +77,7 @@ export const createSshServer = ({ if (!forward) { return undefined } + storeKeyToClient.set(key, client) tunnels.set(requestId, tunnelUrl(clientId, tunnelPath)) const onForwardClose = (event: 'close' | 'error') => (err?: Error) => { if (err) { @@ -72,6 +86,7 @@ export const createSshServer = ({ log.info('%s: deleting tunnel %s', event, key) } tunnels.delete(requestId) + storeKeyToClient.delete(key) void activeTunnelStore.delete(key, setTx) tunnelsGauge.dec({ clientId }) } @@ -106,4 +121,11 @@ export const createSshServer = ({ reject() return undefined }) - }) + } + + return baseSshServer({ + log: serverLog, + sshPrivateKey, + socketDir, + }).on('client', onClient) +} diff --git a/tunnel-server/src/tunnel-store/index.test.ts b/tunnel-server/src/tunnel-store/index.test.ts index 77f2d004..8c113c5e 100644 --- a/tunnel-server/src/tunnel-store/index.test.ts +++ b/tunnel-server/src/tunnel-store/index.test.ts @@ -1,27 +1,36 @@ -import { describe, it, expect, beforeEach } from '@jest/globals' +import { describe, it, expect, beforeEach, jest } from '@jest/globals' import pinoPretty from 'pino-pretty' import { Logger, pino } from 'pino' -import { ActiveTunnel, ActiveTunnelStore, TransactionDescriptor, inMemoryActiveTunnelStore } from '.' +import { nextTick } from 'process' +import { ActiveTunnel, ActiveTunnelStore, inMemoryActiveTunnelStore } from '.' +import { EntryWatcher, TransactionDescriptor } from '../memory-store' describe('inMemoryActiveTunnelStore', () => { let store: ActiveTunnelStore let log: Logger beforeEach(() => { - log = pino({ level: 'debug' }, pinoPretty()) + log = pino({ level: 'silent' }, pinoPretty()) store = inMemoryActiveTunnelStore({ log }) }) describe('when setting a new key', () => { - let desc: TransactionDescriptor + let tx: TransactionDescriptor let val: ActiveTunnel + let watcher: EntryWatcher beforeEach(async () => { val = { publicKeyThumbprint: 'pk1' } as ActiveTunnel - desc = await store.set('foo', val) + const setResult = await store.set('foo', val) + tx = setResult.tx + watcher = setResult.watcher }) it('returns a descriptor', async () => { - expect(desc).toBeDefined() + expect(tx).toBeDefined() + }) + + it('returns a watcher', async () => { + expect(watcher).toBeDefined() }) describe('when getting a non-existant key', () => { @@ -43,13 +52,20 @@ describe('inMemoryActiveTunnelStore', () => { describe('when getting the key', () => { let gotVal: ActiveTunnel | undefined + let gotWatcher: EntryWatcher | undefined beforeEach(async () => { - gotVal = await store.get('foo') + const getResult = await store.get('foo') + gotVal = getResult?.value + gotWatcher = getResult?.watcher }) it('returns the value', () => { expect(gotVal).toBe(val) }) + + it('returns a watcher', () => { + expect(gotWatcher).toBeDefined() + }) }) describe('when getting an existing value by thumbprint', () => { @@ -65,8 +81,13 @@ describe('inMemoryActiveTunnelStore', () => { }) describe('when deleting a non-existant value', () => { + let deleteResult: boolean beforeEach(async () => { - await store.delete('bar') + deleteResult = await store.delete('bar') + }) + + it('returns false', () => { + expect(deleteResult).toBe(false) }) describe('when getting a non-existing value by thumbprint', () => { @@ -94,8 +115,13 @@ describe('inMemoryActiveTunnelStore', () => { }) describe('when deleting an existing value without a tx arg', () => { + let deleteResult: boolean beforeEach(async () => { - await store.delete('foo') + deleteResult = await store.delete('foo') + }) + + it('returns true', () => { + expect(deleteResult).toBe(true) }) describe('when getting the deleted key', () => { @@ -117,8 +143,13 @@ describe('inMemoryActiveTunnelStore', () => { }) describe('when deleting an existing value with a correct tx arg', () => { + let deleteResult: boolean beforeEach(async () => { - await store.delete('foo', desc) + deleteResult = await store.delete('foo', tx) + }) + + it('returns true', () => { + expect(deleteResult).toBe(true) }) describe('when getting the deleted key', () => { @@ -140,14 +171,19 @@ describe('inMemoryActiveTunnelStore', () => { }) describe('when deleting an existing value with an incorrect tx arg', () => { + let deleteResult: boolean beforeEach(async () => { - await store.delete('foo', { txId: -1 }) + deleteResult = await store.delete('foo', { txId: -1 }) + }) + + it('returns false', () => { + expect(deleteResult).toBe(false) }) describe('when getting the key', () => { let gotVal: ActiveTunnel | undefined beforeEach(async () => { - gotVal = await store.get('foo') + gotVal = (await store.get('foo'))?.value }) it('returns the value', () => { @@ -167,5 +203,41 @@ describe('inMemoryActiveTunnelStore', () => { }) }) }) + + describe('when watching an item', () => { + let deleteListener: jest.Mock<() => void> + beforeEach(() => { + deleteListener = jest.fn<() => void>() + watcher.once('delete', deleteListener) + }) + + describe('when deleting the item', () => { + beforeEach(async () => { + await store.delete('foo') + await new Promise(nextTick) + }) + + it('calls the delete listener on the next tick', () => { + expect(deleteListener).toHaveBeenCalled() + }) + }) + + describe('when another item is in the store', () => { + beforeEach(async () => { + await store.set('bar', { publicKeyThumbprint: 'pk2' } as ActiveTunnel) + }) + + describe('when deleting the other item', () => { + beforeEach(async () => { + await store.delete('bar') + await new Promise(nextTick) + }) + + it("does not call the first item's delete listener on the next tick", () => { + expect(deleteListener).not.toHaveBeenCalled() + }) + }) + }) + }) }) }) diff --git a/tunnel-server/src/tunnel-store/index.ts b/tunnel-server/src/tunnel-store/index.ts index 26c2758f..6df0a7ac 100644 --- a/tunnel-server/src/tunnel-store/index.ts +++ b/tunnel-server/src/tunnel-store/index.ts @@ -1,6 +1,7 @@ import { KeyObject } from 'crypto' import { Logger } from 'pino' -import { multimap } from './array-map' +import { multimap } from '../multimap' +import { Store, TransactionDescriptor, inMemoryStore } from '../memory-store' export { activeTunnelStoreKey } from './key' @@ -25,58 +26,35 @@ export type ActiveTunnel = { access: 'private' | 'public' meta: Record inject?: ScriptInjection[] + client: unknown } -export class KeyAlreadyExistsError extends Error { - constructor(readonly key: string) { - super(`key already exists: "${key}"`) - } -} - -export type TransactionDescriptor = { txId: number } - -export type ActiveTunnelStore = { - get: (key: string) => Promise +export type ActiveTunnelStore = Store & { getByPkThumbprint: (pkThumbprint: string) => Promise - set: (key: string, value: ActiveTunnel) => Promise - delete: (key: string, tx?: TransactionDescriptor) => Promise -} - -const idGenerator = () => { - let nextId = 0 - return { - next: () => { - const result = nextId - nextId += 1 - return result - }, - } } export const inMemoryActiveTunnelStore = ({ log }: { log: Logger }): ActiveTunnelStore => { - const keyToTunnel = new Map() - const pkThumbprintToTunnel = multimap() - const txIdGen = idGenerator() - return { - get: async key => keyToTunnel.get(key), - getByPkThumbprint: async pkThumbprint => pkThumbprintToTunnel.get(pkThumbprint) - ?.map(key => keyToTunnel.get(key) as ActiveTunnel), - set: async (key, value) => { - if (keyToTunnel.has(key)) { - throw new KeyAlreadyExistsError(key) - } - const txId = txIdGen.next() - log.debug('setting tunnel key %s id %s: %j', key, txId, value) - keyToTunnel.set(key, Object.assign(value, { txId })) - pkThumbprintToTunnel.add(value.publicKeyThumbprint, key) - return { txId } + const keyToTunnel = inMemoryStore({ log }) + const pkThumbprintToTunnel = multimap() + const { set: storeSet } = keyToTunnel + return Object.assign(keyToTunnel, { + getByPkThumbprint: async (pkThumbprint: string) => { + const entries = pkThumbprintToTunnel.get(pkThumbprint) ?? [] + const result = ( + await Promise.all(entries.map(async ({ key }) => (await keyToTunnel.get(key))?.value)) + ).filter(Boolean) as ActiveTunnel[] + return result.length ? result : undefined }, - delete: async (key, tx) => { - const tunnel = keyToTunnel.get(key) - if (tunnel && (tx === undefined || tunnel.txId === tx.txId)) { - pkThumbprintToTunnel.delete(tunnel.publicKeyThumbprint, k => k === key) - keyToTunnel.delete(key) - } + set: async (key: string, value: ActiveTunnel) => { + const result = await storeSet(key, value) + pkThumbprintToTunnel.add(value.publicKeyThumbprint, { key, tx: result.tx }) + result.watcher.once('delete', () => { + pkThumbprintToTunnel.delete( + value.publicKeyThumbprint, + entry => entry.key === key && entry.tx.txId === result.tx.txId, + ) + }) + return result }, - } + }) } diff --git a/tunnel-server/src/url.test.ts b/tunnel-server/src/url.test.ts index 442deebe..8d2851d8 100644 --- a/tunnel-server/src/url.test.ts +++ b/tunnel-server/src/url.test.ts @@ -5,24 +5,30 @@ describe('url', () => { describe('editUrl', () => { let baseUrl: URL beforeEach(() => { - baseUrl = new URL('http://example.com/?x=12&y=13') + baseUrl = new URL('http://example.com/mypath?x=12&y=13') }) describe('when given a hostname', () => { it('should override the hostname', () => { - expect(editUrl(baseUrl, { hostname: 'other.org' }).toJSON()).toEqual(new URL('http://other.org/?x=12&y=13').toJSON()) + expect(editUrl(baseUrl, { hostname: 'other.org' }).toJSON()).toEqual(new URL('http://other.org/mypath?x=12&y=13').toJSON()) }) }) describe('when given query params', () => { it('should override the given query params', () => { - expect(editUrl(baseUrl, { queryParams: { x: '15', z: '16' } }).toJSON()).toEqual(new URL('http://example.com/?x=15&z=16&y=13').toJSON()) + expect(editUrl(baseUrl, { queryParams: { x: '15', z: '16' } }).toJSON()).toEqual(new URL('http://example.com/mypath?x=15&z=16&y=13').toJSON()) }) }) describe('when given username and password', () => { it('should override the username and password', () => { - expect(editUrl(baseUrl, { username: 'user1', password: 'hunter2' }).toJSON()).toEqual(new URL('http://user1:hunter2@example.com/?x=12&y=13').toJSON()) + expect(editUrl(baseUrl, { username: 'user1', password: 'hunter2' }).toJSON()).toEqual(new URL('http://user1:hunter2@example.com/mypath?x=12&y=13').toJSON()) + }) + }) + + describe('when given a path', () => { + it('should override the path', () => { + expect(editUrl(baseUrl, { path: 'otherpath' }).toJSON()).toEqual(new URL('http://example.com/otherpath?x=12&y=13').toJSON()) }) }) }) diff --git a/tunnel-server/src/url.ts b/tunnel-server/src/url.ts index e59ca393..b2c65a3f 100644 --- a/tunnel-server/src/url.ts +++ b/tunnel-server/src/url.ts @@ -2,13 +2,14 @@ import lodash from 'lodash' export const editUrl = ( url: URL | string, - { hostname, queryParams, username, password }: Partial<{ + { hostname, queryParams, username, password, path }: Partial<{ hostname: string queryParams: Record username: string password: string + path: string }>, -) => { +): URL => { const u = new URL(url.toString()) return Object.assign(u, { ...hostname ? { hostname } : {}, @@ -17,5 +18,6 @@ export const editUrl = ( } : {}, ...username ? { username } : {}, ...password ? { password } : {}, + ...path ? { pathname: path } : {}, }) } diff --git a/tunnel-server/yarn.lock b/tunnel-server/yarn.lock index f6428baf..1fa6a9d3 100644 --- a/tunnel-server/yarn.lock +++ b/tunnel-server/yarn.lock @@ -852,6 +852,14 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/node-fetch@^2.6.4": + version "2.6.4" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660" + integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*", "@types/node@^18.11.18": version "18.13.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.13.0.tgz#0400d1e6ce87e9d3032c19eb6c58205b0d3f7850" @@ -1134,6 +1142,11 @@ asn1@^0.2.6: dependencies: safer-buffer "~2.1.0" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -1414,6 +1427,13 @@ colorette@^2.0.7: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1503,6 +1523,11 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -1956,6 +1981,15 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +form-data@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" + integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -2827,6 +2861,18 @@ micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -2876,6 +2922,13 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-fetch@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" + integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -2980,6 +3033,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-timeout@^6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.2.tgz#22b8d8a78abf5e103030211c5fc6dee1166a6aa5" + integrity sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ== + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -3577,6 +3635,11 @@ touch@^3.1.0: dependencies: nopt "~1.0.10" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + ts-jest@^29.1.0: version "29.1.1" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b" @@ -3615,6 +3678,11 @@ ts-pattern@^5.0.4: resolved "https://registry.yarnpkg.com/ts-pattern/-/ts-pattern-5.0.4.tgz#11508e1fb09c4a65b3fa85fd297941792c0ab7d1" integrity sha512-D5iVliqugv2C9541W2CNXFYNEZxr4TiHuLPuf49tKEdQFp/8y8fR0v1RExUvXkiWozKCwE7zv07C6EKxf0lKuQ== +tseep@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tseep/-/tseep-1.1.1.tgz#76d333d4a354cbfc627e957e49903cc53f46e17e" + integrity sha512-w2MjaqNWGDeliT5/W+/lhhnR0URiVwXXsXbkAZjQVywrOpdKhQAOL1ycyHfNOV1QBjouC7FawNmJePet1sGesw== + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -3715,6 +3783,19 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"