/** * Tests for electron/mobile-relay-client.ts — the turkservis.online tunnel * client (contract: aiturk-mobil-tunel-spec.md v3, §5 "IDE"). * * Part 1 drives the whole path the way a phone does: a fake relay (an * in-process WebSocket server on 127.0.0.1 that speaks the gateway's host * protocol) hands out a byte stream, the test builds REAL TLS on top of that * stream with node:tls and pins the certificate fingerprint, and talks HTTP / * WebSocket to the real LAN proxy behind it. Part 2 checks the client alone * with a fake socket and a fake clock. * * Nothing leaves 127.0.0.1; turkservis.online is never contacted. */ import crypto from 'node:crypto' import { EventEmitter } from 'node:events' import http from 'node:http' import net from 'node:net' import { Duplex } from 'node:stream' import tls from 'node:tls' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { WebSocket, WebSocketServer } from 'ws' import { fingerprintOfDer } from './mobile-lan-cert' import { createMobileLink, type MobileLinkEvent } from './mobile-lan-proxy' import { createRelayClient, encodeRelayFrame, isRelayId, MOBILE_NETWORK_RETRY_DELAY_MS, parseRelaySession, registerRelaySession, RELAY_BACKOFF_MS, RELAY_BUFFER_LIMIT_BYTES, RELAY_CHUNK_BYTES, RELAY_MAX_STREAMS, RELAY_SILENCE_MS, type RelayCredentials, relayHostUrl, splitRelayChunks } from './mobile-relay-client' const SESSION = crypto.randomBytes(32).toString('base64url') const RELAY_IDS = ['3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77'] const HOST_TOKENS = [`tsmr_${'A'.repeat(43)}`, `tsmr_${'B'.repeat(43)}`] const BIG = crypto.randomBytes(1536 * 1024) const sha = (data: Buffer) => crypto.createHash('sha256').update(data).digest('hex') async function until(check: () => boolean, ms = 5000) { const end = Date.now() + ms while (!check()) { if (Date.now() > end) { throw new Error('condition not reached in time') } await new Promise(resolve => setTimeout(resolve, 10)) } } // ── fake backend (what the proxy forwards to) ─────────────────────────────── let backend: http.Server let backendPort = 0 let backendWss: WebSocketServer beforeAll(async () => { backend = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://x') if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }) return res.end(``) } if (url.pathname !== '/api/status' && req.headers['x-hermes-session-token'] !== SESSION) { res.writeHead(401, { 'content-type': 'application/json' }) return res.end('{"detail":"Unauthorized"}') } if (url.pathname === '/api/big') { res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(BIG.length) }) return res.end(BIG) } if (url.pathname === '/api/echo') { const chunks: Buffer[] = [] req.on('data', chunk => chunks.push(chunk)) req.on('end', () => { const body = Buffer.concat(chunks) res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ bytes: body.length, sha256: sha(body) })) }) return } res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ ok: true, path: url.pathname })) }) backendWss = new WebSocketServer({ noServer: true }) backend.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', 'http://x') if (url.searchParams.get('token') !== SESSION) { return socket.end('HTTP/1.1 403 Forbidden\r\n\r\n') } backendWss.handleUpgrade(req, socket, head, ws => ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }))) }) await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve)) backendPort = (backend.address() as net.AddressInfo).port }) afterAll(async () => { backendWss.close() backend.closeAllConnections() await new Promise(resolve => backend.close(resolve)) }) // ── fake relay: the gateway side of `WS /host/` ──────────────────── async function startFakeRelay() { const tokens = new Map() const wss = new WebSocketServer({ host: '127.0.0.1', port: 0, maxPayload: 1024 * 1024 }) const streams = new Map void; closedByHost: null | number }>() /** Every data payload that crossed the relay, both directions: what turkservis.online would see. */ const seen: Buffer[] = [] const connections: { relayId: string; authorization: string | undefined }[] = [] const closes: { id: number; code: number }[] = [] let frameSizes: number[] = [] let host: null | WebSocket = null let nextId = 7 let pongs = 0 await new Promise(resolve => wss.once('listening', resolve)) wss.on('connection', (ws, req) => { const relayId = String(req.url).split('/').pop() ?? '' connections.push({ relayId, authorization: req.headers.authorization }) if (!tokens.has(relayId) || req.headers.authorization !== `Bearer ${tokens.get(relayId)}`) { return ws.close(4401) } host?.close(4409) host = ws ws.on('close', () => { if (host === ws) { host = null } for (const stream of streams.values()) { stream.duplex.destroy() } streams.clear() }) ws.on('message', (data, isBinary) => { const frame = data as Buffer if (isBinary) { const stream = streams.get(frame.readUInt32BE(0)) frameSizes.push(frame.length - 4) seen.push(frame.subarray(4)) stream?.duplex.push(frame.subarray(4)) return } const message = JSON.parse(frame.toString('utf8')) if (message.t === 'pong') { pongs++ } else if (message.t === 'opened') { streams.get(message.id)?.opened() } else if (message.t === 'close') { const stream = streams.get(message.id) closes.push({ id: message.id, code: message.code }) if (stream) { stream.closedByHost = message.code streams.delete(message.id) stream.duplex.push(null) stream.duplex.destroy() } } }) }) return { port: (wss.address() as net.AddressInfo).port, tokens, seen, connections, closes, get host() { return host }, get pongs() { return pongs }, get streamCount() { return streams.size }, takeFrameSizes() { const sizes = frameSizes frameSizes = [] return sizes }, /** What the gateway does when a phone connects to `/t/`: `open`, wait for `opened`, then raw bytes. */ openStream(): Promise { return new Promise((resolve, reject) => { const ws = host const id = nextId++ if (!ws) { return reject(new Error('host_offline')) } const entry = { closedByHost: null as null | number, opened: () => resolve(duplex), duplex: null as unknown as Duplex } const duplex = new Duplex({ read() {}, write(chunk: Buffer, _encoding, done) { for (const part of splitRelayChunks(chunk)) { seen.push(part) ws.send(encodeRelayFrame(id, part)) } done() }, destroy(error, done) { if (streams.get(id) === entry) { streams.delete(id) ws.send(JSON.stringify({ t: 'close', id, code: 1000 })) } done(error) } }) entry.duplex = duplex streams.set(id, entry) ws.send(JSON.stringify({ t: 'open', id })) setTimeout(() => reject(new Error(`not opened (host closed with ${entry.closedByHost})`)), 3000) }) }, send: (value: unknown) => host?.send(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)), stop: () => new Promise(resolve => { for (const client of wss.clients) { client.terminate() } wss.close(() => resolve()) }) } } type FakeRelay = Awaited> /** The phone: TLS inside the tunnel stream, trusting ONLY the pinned fingerprint. */ async function phoneTls(relay: FakeRelay, cert: string, fingerprint: string): Promise { const stream = await relay.openStream() return new Promise((resolve, reject) => { const socket = tls.connect({ socket: stream, ca: cert, checkServerIdentity: (_host, peer) => (fingerprintOfDer(peer.raw) === fingerprint ? undefined : new Error('tls:pin')) }) socket.once('secureConnect', () => resolve(socket)) socket.once('error', reject) }) } interface PhoneCall { token?: string body?: Buffer | string headers?: Record } async function phoneCall(relay: FakeRelay, pin: { cert: string; fingerprint: string }, method: string, path: string, options: PhoneCall = {}) { const socket = await phoneTls(relay, pin.cert, pin.fingerprint) return new Promise<{ status: number; body: Buffer; json: any }>((resolve, reject) => { const req = http.request( { // No agent: Node then uses exactly this socket (the TLS session inside the tunnel). createConnection: () => socket, host: '127.0.0.1', port: 9, // never dialled: the TLS socket inside the tunnel is the connection method, path, headers: { ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), ...(options.body ? { 'content-length': String(Buffer.byteLength(options.body)) } : {}), ...options.headers } }, res => { const chunks: Buffer[] = [] res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const body = Buffer.concat(chunks) let json: any = null try { json = JSON.parse(body.toString('utf8')) } catch { json = null } socket.destroy() resolve({ status: res.statusCode ?? 0, body, json }) }) } ) req.on('error', error => reject(new Error(`${method} ${path}: ${error.message}`))) req.end(options.body) }) } // ── controller wired to the fake relay ────────────────────────────────────── function tunnelHarness(relay: FakeRelay) { const vault = { value: {} as Record } const store = { value: null as any } const events: MobileLinkEvent[] = [] const logs: string[] = [] const firewallCalls: string[] = [] const dialed: string[] = [] let registered = 0 const link = createMobileLink({ vault: { load: async () => structuredClone(vault.value), save: async (value: Record) => { vault.value = structuredClone(value) } }, store: { load: async () => structuredClone(store.value), save: async (value: any) => { store.value = structuredClone(value) } }, resolveBackend: async () => ({ baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION }), isRemoteMode: () => false, firewall: { addRule: async (port: number) => { firewallCalls.push(`add:${port}`) return { ok: true } }, removeRule: async () => { firewallCalls.push('remove') return { ok: true } }, queryRule: async () => ({ state: 'absent' }) }, fetch: async (url: string) => { if (url.endsWith('/mobile-relay/sessions')) { const index = registered++ relay.tokens.set(RELAY_IDS[index], HOST_TOKENS[index]) return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) } } return { status: 201, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) } }, // The real `ws` client with the real header; only the origin is swapped for the fake relay. relayConnect: (url: string, headers: Record) => { dialed.push(url) return new WebSocket(url.replace('wss://turkservis.online/v1/aiturk/mobile-relay', `ws://127.0.0.1:${relay.port}`), { headers, perMessageDeflate: false }) }, addresses: async () => ['192.168.1.20'], version: '1.0.0-test', pcName: 'TEST-PC', platform: 'win32', listenHost: '127.0.0.1', ports: [0], log: (line: string) => logs.push(line) } as any) link.onEvent(event => events.push(event)) return { link, vault, store, events, logs, firewallCalls, dialed } } describe('tunnel: phone → fake relay → relay client → real TLS proxy', () => { it('real end-to-end TLS through the tunnel: hello, 401, 404, 403, claim, status, WebSocket; the relay sees ciphertext only', async () => { const relay = await startFakeRelay() const h = tunnelHarness(relay) await h.link.enable() expect(h.firewallCalls).toEqual([]) const state = await h.link.pairBegin() const pin = { cert: h.vault.value.lanTls.cert as string, fingerprint: h.vault.value.lanTls.fingerprint as string } expect(state.pairing!.qr).toBe(`aiturk://pair?v=1&fp=${pin.fingerprint}&c=${state.pairing!.code}&r=${RELAY_IDS[0]}`) expect(isRelayId(new URLSearchParams(state.pairing!.qr.split('?')[1]).get('r'))).toBe(true) await until(() => relay.host !== null && h.events.some(event => event.state.relay.status === 'connected')) expect(h.dialed).toEqual([`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[0]}`]) expect(relay.connections).toEqual([{ relayId: RELAY_IDS[0], authorization: `Bearer ${HOST_TOKENS[0]}` }]) expect(await h.link.state()).toMatchObject({ listening: true, relay: { status: 'connected', ready: true }, lan: { direct: false, listening: false } }) // The certificate the phone sees through the tunnel is the proxy's own: nobody terminated TLS on the way. const probe = await phoneTls(relay, pin.cert, pin.fingerprint) expect(fingerprintOfDer(probe.getPeerCertificate().raw)).toBe(pin.fingerprint) probe.destroy() await expect(phoneTls(relay, pin.cert, 'x'.repeat(43))).rejects.toThrow('tls:pin') const hello = await phoneCall(relay, pin, 'GET', '/aiturk/pair/hello') expect(hello.status).toBe(200) expect(hello.json).toEqual({ app: 'aiturk-ide', version: '1.0.0-test', pairing: true }) // The proxy's rules apply unchanged: the tunnel is not a way around them. expect((await phoneCall(relay, pin, 'GET', '/api/sessions')).status).toBe(401) expect((await phoneCall(relay, pin, 'GET', '/api/sessions', { token: SESSION })).status).toBe(401) const root = await phoneCall(relay, pin, 'GET', '/') expect(root.status).toBe(404) expect(root.body.toString()).not.toContain(SESSION) const claim = await phoneCall(relay, pin, 'POST', '/aiturk/pair/claim', { body: JSON.stringify({ code: state.pairing!.code, device_name: 'Pixel 9' }), headers: { 'content-type': 'application/json' } }) expect(claim.status).toBe(200) expect(claim.json.token).toMatch(/^amd_[A-Za-z0-9_-]{43}$/) await until(() => h.events.some(event => event.type === 'paired')) const token = claim.json.token as string const status = await phoneCall(relay, pin, 'GET', '/api/status', { token }) expect(status.status).toBe(200) expect(status.json).toEqual({ ok: true, path: '/api/status' }) expect((await phoneCall(relay, pin, 'GET', '/api/env', { token })).status).toBe(403) expect((await phoneCall(relay, pin, 'POST', '/api/config', { token, body: '{}' })).status).toBe(403) // ≥ 1 MiB each way, byte for byte, in frames of at most 64 KiB. relay.takeFrameSizes() const big = await phoneCall(relay, pin, 'GET', '/api/big', { token }) expect(big.status).toBe(200) expect(big.body.length).toBe(BIG.length) expect(sha(big.body)).toBe(sha(BIG)) const sizes = relay.takeFrameSizes() expect(sizes.reduce((sum, size) => sum + size, 0)).toBeGreaterThan(BIG.length) expect(Math.max(...sizes)).toBeLessThanOrEqual(RELAY_CHUNK_BYTES) const upload = await phoneCall(relay, pin, 'POST', '/api/echo', { token, body: BIG, headers: { 'content-type': 'application/octet-stream' } }) expect(upload.json).toEqual({ bytes: BIG.length, sha256: sha(BIG) }) // WebSocket (101) with the device token in the Authorization header; text and binary both ways; close. const wsSocket = await phoneTls(relay, pin.cert, pin.fingerprint) const ws = new WebSocket('ws://127.0.0.1:9/api/ws', { createConnection: () => wsSocket, headers: { authorization: `Bearer ${token}` } } as any) const got: { data: Buffer; binary: boolean }[] = [] ws.on('message', (data, binary) => got.push({ data: data as Buffer, binary })) await new Promise((resolve, reject) => { ws.once('open', resolve) ws.once('error', reject) ws.once('unexpected-response', (_req, res) => reject(new Error(`ws ${res.statusCode}`))) }) ws.send('merhaba telefon') ws.send(Buffer.from([0, 1, 2, 254, 255])) await until(() => got.length === 2) expect(got[0]).toEqual({ data: Buffer.from('merhaba telefon'), binary: false }) expect(got[1]).toEqual({ data: Buffer.from([0, 1, 2, 254, 255]), binary: true }) const closed = new Promise(resolve => ws.once('close', code => resolve(code))) ws.close(1000) expect(await closed).toBe(1000) // Forbidden WebSocket path and a missing token are refused by the proxy, through the tunnel too. for (const [path, headers, expected] of [ ['/api/pty', { authorization: `Bearer ${token}` }, 403], ['/api/ws', {}, 401] ] as const) { const refusedSocket = await phoneTls(relay, pin.cert, pin.fingerprint) const refused = new WebSocket(`ws://127.0.0.1:9${path}`, { createConnection: () => refusedSocket, headers } as any) const outcome = await new Promise(resolve => { refused.once('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)) refused.once('open', () => resolve(101)) refused.once('error', () => resolve(0)) }) expect(outcome).toBe(expected) refusedSocket.destroy() } // What the relay carried: TLS records. No token, no code, no path, no HTTP, no plaintext payload. const wire = Buffer.concat(relay.seen) expect(wire.length).toBeGreaterThan(2 * BIG.length) for (const secret of [token, token.slice(4), state.pairing!.code, SESSION, 'HTTP/1.1', '/api/status', 'aiturk/pair', 'merhaba telefon', 'authorization']) { expect(wire.includes(Buffer.from(secret))).toBe(false) } expect(wire.includes(BIG.subarray(0, 64))).toBe(false) // Streams are released when the phone side ends. await until(() => relay.streamCount === 0) // Revoking the phone in the IDE kills its token on the tunnel path as well; with no phone left the relay socket closes. await h.link.deviceRevoke(claim.json.device_id) await until(() => relay.host === null) expect((await h.link.state()).relay.status).toBe('off') await expect(relay.openStream()).rejects.toThrow('host_offline') // The host token stayed in the vault and the Authorization header. const exposed = [h.logs.join('\n'), JSON.stringify(h.events), JSON.stringify(await h.link.state()), JSON.stringify(h.store.value)].join('\n') expect(exposed).not.toContain('tsmr_') expect(exposed).not.toContain(token) expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[0]) await h.link.stop() await relay.stop() }, 30_000) it('a dropped relay socket ends the phone streams and the client comes back; a stream with no listener is refused with 4503', async () => { const relay = await startFakeRelay() const timers: { fn: () => void; ms: number }[] = [] const states: string[] = [] let port: null | number = null const server = net.createServer(socket => socket.on('data', data => socket.write(data)).on('error', () => undefined)) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) relay.tokens.set(RELAY_IDS[0], HOST_TOKENS[0]) const client = createRelayClient({ connect: (url, headers) => new WebSocket(url.replace('wss://turkservis.online/v1/aiturk/mobile-relay', `ws://127.0.0.1:${relay.port}`), { headers }), fetch: async () => ({ status: 500, json: async () => ({}) }), dial: () => (port === null ? null : net.connect(port, '127.0.0.1')), credentials: { load: async () => ({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }), save: async () => undefined, clear: async () => undefined }, // Only the reconnect timer is faked; the 75 s silence watchdog is simply never fired. setTimer: (fn, ms) => { timers.push({ fn, ms }) return fn }, clearTimer: handle => { const index = timers.findIndex(timer => timer.fn === handle) if (index >= 0) { timers.splice(index, 1) } }, onState: state => states.push(`${state.status}${state.code ? `:${state.code}` : ''}`) }) client.start() await until(() => relay.host !== null && client.state().status === 'connected') // No listener behind the client: the stream is closed with 4503 instead of hanging. await expect(relay.openStream()).rejects.toThrow('4503') port = (server.address() as net.AddressInfo).port const stream = await relay.openStream() const echoed: Buffer[] = [] stream.on('data', chunk => echoed.push(chunk)) stream.write(Buffer.from('ping-1')) await until(() => Buffer.concat(echoed).toString() === 'ping-1') expect(client.streams()).toBe(1) relay.send({ t: 'ping' }) await until(() => relay.pongs === 1) // The gateway goes away. const ended = new Promise(resolve => stream.once('close', resolve)) relay.host!.terminate() await ended await until(() => client.state().status === 'failed') expect(client.streams()).toBe(0) expect(states.at(-1)).toBe('failed:connect_failed') expect(timers.filter(timer => timer.ms === RELAY_BACKOFF_MS[0])).toHaveLength(1) timers.find(timer => timer.ms === RELAY_BACKOFF_MS[0])!.fn() await until(() => relay.host !== null && client.state().status === 'connected') const again = await relay.openStream() again.write(Buffer.from('ping-2')) await new Promise(resolve => again.once('data', () => resolve())) // The gateway closes one stream: the local connection goes with it, the others stay. again.destroy() await until(() => client.streams() === 0) client.stop() await until(() => relay.host === null) expect(client.state()).toEqual({ status: 'off', code: null }) await relay.stop() await new Promise(resolve => server.close(resolve)) }, 20_000) }) // ── the client alone: fake socket, fake clock ─────────────────────────────── class FakeSocket extends EventEmitter { sent: (Buffer | string)[] = [] closed: null | number = null terminated = false bufferedAmount = 0 constructor( public url: string, public headers: Record ) { super() } send(data: Buffer | string) { this.sent.push(data) } close(code = 1000) { this.closed = code } terminate() { this.terminated = true } control() { return this.sent.filter((item): item is string => typeof item === 'string').map(item => JSON.parse(item)) } binary() { return this.sent.filter((item): item is Buffer => Buffer.isBuffer(item)) } } class FakeLocal extends EventEmitter { written: Buffer[] = [] destroyed = false paused = 0 resumed = 0 writableLength = 0 write(data: Buffer) { this.written.push(data) return true } destroy() { if (!this.destroyed) { this.destroyed = true this.emit('close') } } pause() { this.paused++ } resume() { this.resumed++ } } function clientHarness(over: { stored?: null | RelayCredentials; sessions?: (number | 'throw')[] } = {}) { const sockets: FakeSocket[] = [] const locals: FakeLocal[] = [] const timers: { fn: () => void; ms: number; id: number }[] = [] const logs: string[] = [] const states: string[] = [] const saved: { credentials: RelayCredentials; reissued: boolean }[] = [] const fetches: { url: string; init: any }[] = [] const sessions = over.sessions ?? [] const box = { stored: over.stored === undefined ? null : over.stored, cleared: 0, dial: true, registered: 0, timerId: 0 } const client = createRelayClient({ connect: (url, headers) => { const socket = new FakeSocket(url, headers) sockets.push(socket) return socket }, fetch: async (url, init) => { fetches.push({ url, init }) const outcome = sessions.shift() ?? 201 if (outcome === 'throw') { throw new Error(`network down ${HOST_TOKENS[0]}`) } const index = box.registered if (outcome === 201) { box.registered++ } return { status: outcome, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) } }, dial: () => { if (!box.dial) { return null } const local = new FakeLocal() locals.push(local) return local }, credentials: { load: async () => box.stored, save: async (credentials, info) => { box.stored = credentials saved.push({ credentials, reissued: info.reissued }) }, clear: async () => { box.stored = null box.cleared++ } }, setTimer: (fn, ms) => { const timer = { fn, ms, id: ++box.timerId } timers.push(timer) return timer }, clearTimer: handle => { const index = timers.indexOf(handle as (typeof timers)[number]) if (index >= 0) { timers.splice(index, 1) } }, log: line => logs.push(line), onState: state => states.push(`${state.status}${state.code ? `:${state.code}` : ''}`) }) /** Fire the pending timer with this delay (the fake clock). */ const fire = (ms: number) => { const index = timers.findIndex(timer => timer.ms === ms) if (index < 0) { throw new Error(`no timer with ${ms} ms; pending: ${timers.map(timer => timer.ms).join(',')}`) } timers.splice(index, 1)[0].fn() } const settle = () => new Promise(resolve => setTimeout(resolve, 0)) const retryDelays = () => timers.filter(timer => timer.ms !== RELAY_SILENCE_MS && timer.ms >= 1000).map(timer => timer.ms) return { client, sockets, locals, timers, logs, states, saved, fetches, box, fire, settle, retryDelays } } describe('relay client', () => { it('registers once, stores the registration, sends the token only in the Authorization header', async () => { const h = clientHarness() expect(h.client.state()).toEqual({ status: 'off', code: null }) h.client.start() await h.settle() expect(h.fetches).toHaveLength(1) expect(h.fetches[0].url).toBe('https://turkservis.online/v1/aiturk/mobile-relay/sessions') expect(h.fetches[0].init).toMatchObject({ method: 'POST', body: '{}', redirect: 'error' }) expect(h.fetches[0].init.signal).toBeInstanceOf(AbortSignal) expect(h.saved).toEqual([{ credentials: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, reissued: false }]) expect(h.sockets[0].url).toBe(relayHostUrl(RELAY_IDS[0])) expect(h.sockets[0].url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[0]}`) expect(h.sockets[0].headers).toEqual({ Authorization: `Bearer ${HOST_TOKENS[0]}` }) expect(h.client.state().status).toBe('connecting') h.sockets[0].emit('open') expect(h.client.state()).toEqual({ status: 'connected', code: null }) h.sockets[0].emit('message', Buffer.from('{"t":"ping"}'), false) expect(h.sockets[0].control()).toEqual([{ t: 'pong' }]) // A stored registration is reused: the phone's saved relay id must not change between runs. const second = clientHarness({ stored: { relayId: RELAY_IDS[1], hostToken: HOST_TOKENS[1] } }) second.client.start() await second.settle() expect(second.fetches).toHaveLength(0) expect(second.sockets[0].headers).toEqual({ Authorization: `Bearer ${HOST_TOKENS[1]}` }) expect(await second.client.ensureRegistered()).toBe(RELAY_IDS[1]) expect(h.logs).toEqual(['relay connecting', 'relay connected']) expect(h.logs.join('\n') + JSON.stringify(h.client.state()) + h.states.join('\n')).not.toContain('tsmr_') }) it('reconnects after 2 s, then with growing delays up to 30 s; a successful open starts over at 2 s', async () => { const h = clientHarness({ stored: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } }) h.client.start() await h.settle() h.sockets[0].emit('open') h.sockets[0].emit('close', 1006) expect(h.client.state()).toEqual({ status: 'failed', code: 'connect_failed' }) const delays: number[] = [] for (let round = 0; round < 7; round++) { const [delay] = h.retryDelays() delays.push(delay) h.fire(delay) await h.settle() expect(h.client.state().status).toBe('connecting') // Connection refused: `ws` reports an error and then the close. h.sockets.at(-1)!.emit('error', new Error('ECONNREFUSED')) h.sockets.at(-1)!.emit('close', 1006) expect(h.retryDelays()).toHaveLength(1) } expect(delays).toEqual([2000, 4000, 8000, 16_000, 30_000, 30_000, 30_000]) expect(RELAY_BACKOFF_MS).toEqual([2000, 4000, 8000, 16_000, 30_000]) h.fire(30_000) await h.settle() h.sockets.at(-1)!.emit('open') expect(h.client.state().status).toBe('connected') h.sockets.at(-1)!.emit('close', 1001) expect(h.retryDelays()).toEqual([2000]) // A silent gateway (no ping for 75 s) is treated as gone. h.fire(2000) await h.settle() h.sockets.at(-1)!.emit('open') h.fire(RELAY_SILENCE_MS) expect(h.sockets.at(-1)!.terminated).toBe(true) expect(h.client.state()).toEqual({ status: 'failed', code: 'timeout' }) // stop() cancels the pending retry. h.client.stop() expect(h.timers).toEqual([]) expect(h.client.state()).toEqual({ status: 'off', code: null }) expect(h.fetches).toHaveLength(0) }) it('4401 replaces the registration and says so; 4409 waits 30 s as "replaced"; 4429 is "busy" with the normal delay', async () => { const h = clientHarness({ stored: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } }) h.client.start() await h.settle() h.sockets[0].emit('open') h.sockets[0].emit('close', 4401) await h.settle() expect(h.box.cleared).toBe(1) expect(h.client.state()).toEqual({ status: 'failed', code: 'token_rejected' }) expect(h.fetches).toHaveLength(0) expect(h.retryDelays()).toEqual([2000]) h.fire(2000) await h.settle() // A NEW registration; the controller learns that the phones' stored id is dead. expect(h.fetches).toHaveLength(1) expect(h.saved).toEqual([{ credentials: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, reissued: true }]) expect(h.sockets).toHaveLength(2) h.sockets[1].emit('open') expect(h.client.state().status).toBe('connected') // Another IDE instance took the registration over: no reconnect loop. h.sockets[1].emit('close', 4409) expect(h.client.state()).toEqual({ status: 'failed', code: 'replaced' }) expect(h.retryDelays()).toEqual([30_000]) expect(h.box.cleared).toBe(1) h.fire(30_000) await h.settle() h.sockets[2].emit('close', 4429) expect(h.client.state()).toEqual({ status: 'failed', code: 'busy' }) expect(h.retryDelays()).toEqual([4000]) // A refusal before the WebSocket exists (HTTP 401 on the upgrade) counts as 4401 too. h.fire(4000) await h.settle() const destroyed: string[] = [] h.sockets[3].emit('unexpected-response', { destroy: () => destroyed.push('req') }, { statusCode: 401, resume: () => destroyed.push('res') }) await h.settle() expect(destroyed).toEqual(['res', 'req']) expect(h.box.cleared).toBe(2) expect(h.client.state()).toEqual({ status: 'failed', code: 'token_rejected' }) expect(h.states).toContain('failed:token_rejected') expect(h.logs.join('\n')).not.toContain('tsmr_') h.client.stop() }) it('a failing registration is a fixed code (no upstream text) and is retried; renew() swaps the registration only when the new one is stored', async () => { const h = clientHarness({ sessions: ['throw', 'throw', 429, 500, 201, 500, 201] }) h.client.start() await h.settle() // A thrown network error gets one more try 0.5 s later; until then nothing is reported as failed. expect(h.client.state()).toEqual({ status: 'connecting', code: null }) h.fire(500) await h.settle() expect(h.fetches).toHaveLength(2) expect(h.client.state()).toEqual({ status: 'failed', code: 'register_failed' }) expect(h.sockets).toHaveLength(0) h.fire(2000) await h.settle() expect(h.client.state()).toEqual({ status: 'failed', code: 'rate_limited' }) h.fire(4000) await h.settle() expect(h.client.state()).toEqual({ status: 'failed', code: 'register_failed' }) expect(await h.client.ensureRegistered()).toBe(RELAY_IDS[0]) h.fire(8000) await h.settle() h.sockets[0].emit('open') expect(h.client.state().status).toBe('connected') expect(await h.client.renew()).toBe(false) expect(h.box.stored).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) expect(h.sockets[0].closed).toBeNull() expect(await h.client.renew()).toBe(true) expect(h.saved.at(-1)).toEqual({ credentials: { relayId: RELAY_IDS[1], hostToken: HOST_TOKENS[1] }, reissued: true }) expect(h.sockets[0].closed).toBe(1000) await h.settle() expect(h.sockets[1].url).toBe(relayHostUrl(RELAY_IDS[1])) expect(h.sockets[1].headers).toEqual({ Authorization: `Bearer ${HOST_TOKENS[1]}` }) expect(h.logs.join('\n') + h.states.join('\n')).not.toContain('tsmr_') expect(h.logs.every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))).toBe(true) h.client.stop() const broken = createRelayClient({ connect: () => { throw new Error('must not connect') }, fetch: async () => ({ status: 201, json: async () => ({}) }), dial: () => null, credentials: { load: async () => { throw new Error(`vault ${HOST_TOKENS[0]}`) }, save: async () => undefined, clear: async () => undefined }, setTimer: () => 0, clearTimer: () => undefined }) broken.start() await h.settle() expect(broken.state()).toEqual({ status: 'failed', code: 'vault_unavailable' }) expect(await broken.ensureRegistered()).toBeNull() broken.stop() }) it('registration: a thrown network error is tried once more after 0.5 s; an HTTP answer or a timeout is not', async () => { const h = clientHarness({ sessions: ['throw', 201] }) const pending = h.client.ensureRegistered() await h.settle() expect(h.fetches).toHaveLength(1) expect(h.timers.map(timer => timer.ms)).toEqual([MOBILE_NETWORK_RETRY_DELAY_MS]) expect(MOBILE_NETWORK_RETRY_DELAY_MS).toBe(500) h.fire(500) expect(await pending).toBe(RELAY_IDS[0]) expect(h.fetches).toHaveLength(2) expect(h.fetches[1].init.signal).not.toBe(h.fetches[0].init.signal) expect(h.saved).toEqual([{ credentials: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, reissued: false }]) // The function itself: exactly one extra try, only for a thrown error. const sleeps: number[] = [] const run = async (outcomes: (number | 'throw' | 'timeout')[]) => { let calls = 0 const result = await registerRelaySession( async () => { const outcome = outcomes[calls++] ?? 201 if (outcome === 'throw') { throw new TypeError('fetch failed') } if (outcome === 'timeout') { throw new DOMException('The operation was aborted due to timeout', 'TimeoutError') } return { status: outcome, json: async () => ({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) } }, { sleep: async ms => { sleeps.push(ms) } } ) return { result, calls } } expect(await run(['throw', 201])).toEqual({ result: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, calls: 2 }) expect(await run(['throw', 'throw', 201])).toEqual({ result: { error: 'register_failed' }, calls: 2 }) expect(await run(['throw', 429])).toEqual({ result: { error: 'rate_limited' }, calls: 2 }) expect(sleeps).toEqual([500, 500, 500]) expect(await run([500, 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 }) expect(await run([429, 201])).toEqual({ result: { error: 'rate_limited' }, calls: 1 }) expect(await run(['timeout', 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 }) expect(sleeps).toHaveLength(3) }) it('streams: opened / close, bytes copied untouched in ≤ 64 KiB frames, unknown ids ignored, stream and buffer limits', async () => { const h = clientHarness({ stored: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } }) h.client.start() await h.settle() const ws = h.sockets[0] ws.emit('open') ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 4_000_000_000 })), false) expect(h.locals).toHaveLength(1) // `opened` only once the TCP connection to the proxy exists. expect(ws.control()).toEqual([]) h.locals[0].emit('connect') expect(ws.control()).toEqual([{ t: 'opened', id: 4_000_000_000 }]) // gateway → local: payload after the 4-byte id, unchanged. const inbound = crypto.randomBytes(1000) ws.emit('message', encodeRelayFrame(4_000_000_000, inbound), true) ws.emit('message', encodeRelayFrame(5, Buffer.from('late')), true) ws.emit('message', Buffer.from([0, 0, 0]), true) expect(Buffer.concat(h.locals[0].written).equals(inbound)).toBe(true) // local → gateway: 200 KiB becomes 64 + 64 + 64 + 8 KiB, each with the stream id in front. const outbound = crypto.randomBytes(200 * 1024) h.locals[0].emit('data', outbound) const frames = ws.binary() expect(frames.map(frame => frame.length - 4)).toEqual([65_536, 65_536, 65_536, 8192]) expect(frames.every(frame => frame.readUInt32BE(0) === 4_000_000_000)).toBe(true) expect(Buffer.concat(frames.map(frame => frame.subarray(4))).equals(outbound)).toBe(true) expect(splitRelayChunks(Buffer.alloc(0))).toEqual([]) // Back-pressure, local → gateway: the proxy socket is paused while the host socket is behind … expect(h.locals[0].paused).toBe(0) ws.bufferedAmount = RELAY_BUFFER_LIMIT_BYTES + 1 h.locals[0].emit('data', Buffer.from('x')) expect(h.locals[0].paused).toBe(1) h.locals[0].emit('data', Buffer.from('y')) expect(h.locals[0].paused).toBe(1) h.fire(25) expect(h.locals[0].resumed).toBe(0) // … and resumed once it has drained. ws.bufferedAmount = 1000 h.fire(25) expect(h.locals[0].resumed).toBe(1) // Back-pressure, gateway → local: a proxy that does not read costs at most 1 MiB, then the stream is closed (4408). h.locals[0].writableLength = RELAY_BUFFER_LIMIT_BYTES + 1 ws.emit('message', encodeRelayFrame(4_000_000_000, Buffer.from('z')), true) expect(h.locals[0].destroyed).toBe(true) expect(ws.control().at(-1)).toEqual({ t: 'close', id: 4_000_000_000, code: 4408 }) expect(h.client.streams()).toBe(0) // With a host socket that can be paused (the real one), reading stops at 256 KiB and continues on drain. const pausable = Object.assign(ws, { pauses: 0, resumes: 0, pause: () => pausable.pauses++, resume: () => pausable.resumes++ }) ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 50 })), false) const slow = h.locals.at(-1)! slow.writableLength = 300 * 1024 ws.emit('message', encodeRelayFrame(50, Buffer.from('a')), true) ws.emit('message', encodeRelayFrame(50, Buffer.from('b')), true) expect([pausable.pauses, pausable.resumes, slow.destroyed]).toEqual([1, 0, false]) slow.writableLength = 0 slow.emit('drain') expect([pausable.pauses, pausable.resumes]).toEqual([1, 1]) // A stalled stream that ends must not leave the host socket paused. slow.writableLength = 300 * 1024 ws.emit('message', encodeRelayFrame(50, Buffer.from('c')), true) slow.destroy() expect([pausable.pauses, pausable.resumes]).toEqual([2, 2]) expect(ws.control().at(-1)).toEqual({ t: 'close', id: 50, code: 1000 }) // The gateway closes a stream: local connection destroyed, no echo of the close. ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 1 })), false) const first = h.locals.length - 1 h.locals[first].emit('connect') const before = ws.control().length ws.emit('message', Buffer.from(JSON.stringify({ t: 'close', id: 1, code: 1000 })), false) expect(h.locals[first].destroyed).toBe(true) expect(ws.control()).toHaveLength(before) // The proxy closes the connection: the gateway is told once (1000 clean, 1011 after an error). ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 2 })), false) ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 3 })), false) h.locals[first + 1].emit('close') h.locals[first + 2].emit('error', new Error('ECONNRESET')) h.locals[first + 2].emit('close') expect(ws.control().slice(-2)).toEqual([ { t: 'close', id: 2, code: 1000 }, { t: 'close', id: 3, code: 1011 } ]) // Malformed control frames and ids are ignored. for (const bad of ['not json', '{"t":"open"}', '{"t":"open","id":-1}', '{"t":"open","id":"7"}', '{"t":"open","id":1.5}', 'null']) { ws.emit('message', Buffer.from(bad), false) } expect(h.client.streams()).toBe(0) // At most 16 streams; the 17th is refused with 4429. Nothing listening → 4503. for (let id = 100; id < 100 + RELAY_MAX_STREAMS; id++) { ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id })), false) } expect(h.client.streams()).toBe(RELAY_MAX_STREAMS) ws.emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 999 })), false) expect(ws.control().at(-1)).toEqual({ t: 'close', id: 999, code: 4429 }) // The relay socket drops: every local connection is closed. const open = h.locals.filter(local => !local.destroyed) expect(open).toHaveLength(RELAY_MAX_STREAMS) ws.emit('close', 1006) expect(open.every(local => local.destroyed)).toBe(true) expect(h.client.streams()).toBe(0) h.fire(2000) await h.settle() h.sockets[1].emit('open') h.box.dial = false h.sockets[1].emit('message', Buffer.from(JSON.stringify({ t: 'open', id: 1 })), false) expect(h.sockets[1].control()).toEqual([{ t: 'close', id: 1, code: 4503 }]) h.client.stop() }) it('session answer and relay id are validated strictly', async () => { const good = { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } expect(parseRelaySession(good)).toEqual(good) expect(parseRelaySession({ ...good, relayId: RELAY_IDS[0].toUpperCase(), hostUrl: 'wss://evil.example/x' })).toEqual(good) for (const bad of [null, 'x', {}, { ...good, relayId: 'x' }, { ...good, hostToken: 'tsmr_short' }, { ...good, hostToken: `amd_${'A'.repeat(43)}` }, { relayId: 5, hostToken: 6 }]) { expect(parseRelaySession(bad)).toBeNull() } expect(isRelayId(RELAY_IDS[0])).toBe(true) for (const bad of [RELAY_IDS[0].toUpperCase(), `${RELAY_IDS[0]}/x`, `https://turkservis.online/${RELAY_IDS[0]}`, '', null, 5]) { expect(isRelayId(bad)).toBe(false) } expect(await registerRelaySession(async () => ({ status: 201, json: async () => good }))).toEqual(good) expect(await registerRelaySession(async () => ({ status: 200, json: async () => good }))).toEqual({ error: 'register_failed' }) expect(await registerRelaySession(async () => ({ status: 429, json: async () => ({}) }))).toEqual({ error: 'rate_limited' }) expect(await registerRelaySession(async () => ({ status: 201, json: async () => ({ relayId: 'x' }) }))).toEqual({ error: 'register_failed' }) }) })