/** * Tests for electron/mobile-lan-proxy.ts — the "Telefon bağlantısı" LAN proxy, * pairing session and controller (contract: aiturk-mobil-eslestirme-spec.md §5). * * Real TLS sockets on 127.0.0.1 only, against a fake backend that mimics the * dashboard's loopback rules (Host check, session token, token in the root * page). No real backend, gateway, firewall or LAN interface is touched. */ import crypto from 'node:crypto' import http from 'node:http' import https from 'node:https' import net from 'node:net' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { WebSocket, WebSocketServer } from 'ws' import { createSelfSignedCertificate } from './mobile-lan-cert' import { buildPairQrPayload, createLanProxy, createMobileLink, createWindowsFirewall, firewallCommand, formatPairCode, generatePairCode, isPhoneForbidden, isPrivateIpv4, MobileLinkError, type MobileLinkEvent, normalizePairCode, PAIR_CODE_ALPHABET, pairLookup, parseMobileLinkStored, selectLanAddresses } from './mobile-lan-proxy' const SESSION = crypto.randomBytes(32).toString('base64url') const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1']) const hostOnly = (value: unknown) => String(value || '').replace(/:\d+$/, '').toLowerCase() const seen: { path: string; host: string; raw: string; method: string }[] = [] const wrongCode = (code: string) => (code === 'ZZZZZZZZ' ? 'YYYYYYYY' : 'ZZZZZZZZ') // ── fake backend: host check + session auth + token in the root page ──────── let backend: http.Server let backendPort = 0 let wss: WebSocketServer let resolveCalls = 0 const resolveBackend = async () => { resolveCalls++ return { baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION } } beforeAll(async () => { backend = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://x') const send = (status: number, body: string, type = 'application/json') => { res.writeHead(status, { 'content-type': type, 'set-cookie': 'x=1' }) res.end(body) } if (!LOOPBACK.has(hostOnly(req.headers.host))) { return send(400, '{"detail":"Invalid Host header"}') } if (url.pathname === '/') { return send(200, ``, 'text/html') } if (url.pathname.startsWith('/api/') && url.pathname !== '/api/status') { const ok = req.headers['x-hermes-session-token'] === SESSION || req.headers.authorization === `Bearer ${SESSION}` || (url.pathname === '/api/files/download' && url.searchParams.get('token') === SESSION) if (!ok) { return send(401, '{"detail":"Unauthorized"}') } } seen.push({ path: req.url ?? '', host: String(req.headers.host), method: String(req.method), raw: JSON.stringify(req.headers) + req.url }) send(200, JSON.stringify({ ok: true, path: url.pathname })) }) wss = new WebSocketServer({ noServer: true }) backend.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', 'http://x') const origin = req.headers.origin const allowed = url.searchParams.get('token') === SESSION && LOOPBACK.has(hostOnly(req.headers.host)) && !req.headers.cookie && (!origin || LOOPBACK.has(new URL(origin).hostname)) // The real backend also accepts /api/pty with a valid token: the block must come from the proxy. if (!allowed || !['/api/ws', '/api/pty', '/api/console', '/api/plugins/kanban/events'].includes(url.pathname)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n') return } wss.handleUpgrade(req, socket, head, ws => ws.on('message', data => { const frame = JSON.parse(String(data)) if (frame.method === 'gateway.ping') { ws.send(JSON.stringify({ jsonrpc: '2.0', result: { ok: true }, id: frame.id })) } }) ) }) await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve)) backendPort = (backend.address() as net.AddressInfo).port }) afterAll(async () => { wss.close() backend.closeAllConnections() await new Promise(resolve => backend.close(resolve)) }) // ── client helpers (what the phone does: trust ONLY the pinned certificate) ─ interface CallOptions { token?: string headerToken?: string body?: unknown rawBody?: string ca?: string headers?: Record } function call(port: number, method: string, path: string, options: CallOptions = {}) { return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>((resolve, reject) => { const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)) const req = https.request( { host: '127.0.0.1', port, method, path, agent: false, ...(options.ca ? { ca: options.ca } : {}), checkServerIdentity: () => undefined, headers: { ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), ...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}), ...(payload ? { 'content-type': 'application/json' } : {}), origin: 'http://evil.example', cookie: 'session=evil', ...options.headers } }, res => { let text = '' res.on('data', chunk => (text += chunk)) res.on('end', () => { let json: any = null try { json = JSON.parse(text) } catch { json = null } resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json }) }) } ) req.on('error', reject) req.end(payload) }) } function wsOpen(url: string, ca: string, extra: Record = {}) { return new Promise<{ open: boolean; ws?: WebSocket; status?: number; error?: string }>(resolve => { const ws = new WebSocket(url, { ca, checkServerIdentity: () => undefined, ...extra } as any) ws.once('open', () => resolve({ ws, open: true })) ws.once('unexpected-response', (_req, res) => { resolve({ open: false, status: res.statusCode }) res.resume() }) ws.once('error', error => resolve({ open: false, error: error.message })) }) } const connectOutcome = (port: number) => new Promise(resolve => { const socket = net.connect(port, '127.0.0.1') socket.once('connect', () => { socket.destroy() resolve('open') }) socket.once('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) }) async function until(check: () => boolean, ms = 3000) { 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)) } } // ── proxy fixture ─────────────────────────────────────────────────────────── async function startProxy(over: Record = {}) { const identity = createSelfSignedCertificate() const clock = { now: 1_800_000_000_000 } const logs: string[] = [] const saved: any[] = [] const ended: string[] = [] const proxy = createLanProxy({ tls: identity, resolveBackend, version: '1.0.0-test', pcName: 'TEST-PC', now: () => clock.now, log: line => logs.push(line), onDevicesChanged: devices => { saved.push(structuredClone(devices)) }, onPairingEnded: reason => { ended.push(reason) }, ...over } as any) const port = await proxy.listen(0, '127.0.0.1') const pair = async (name = 'Pixel 9') => { const { code } = proxy.beginPairing() const r = await call(port, 'POST', '/aiturk/pair/claim', { ca: identity.cert, body: { code, device_name: name } }) expect(r.status).toBe(200) return { code, token: r.json.token as string, deviceId: r.json.device_id as string, response: r } } return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert } } describe('LAN proxy: pairing (hello / claim)', () => { it('hello is unauthenticated and reports whether pairing is open', async () => { const t = await startProxy() expect((await call(t.port, 'GET', '/aiturk/pair/hello', { ca: t.ca })).json).toEqual({ app: 'aiturk-ide', version: '1.0.0-test', pairing: false }) t.proxy.beginPairing() expect((await call(t.port, 'GET', '/aiturk/pair/hello', { ca: t.ca })).json.pairing).toBe(true) await t.proxy.stop() }) it('claim is 410 pairing_closed while pairing is not open', async () => { const t = await startProxy() const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: 'AAAAAAAA', device_name: 'x' } }) expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) await t.proxy.stop() }) it('four wrong codes are 401 wrong_code; the right code (lower case, dashed) then yields a device token', async () => { const t = await startProxy() const { code } = t.proxy.beginPairing() for (let i = 0; i < 4; i++) { const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) expect([r.status, r.json]).toEqual([401, { error: 'wrong_code' }]) } const typed = `${code.slice(0, 4).toLowerCase()}-${code.slice(4).toLowerCase()} ` const ok = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: typed, device_name: ' Pixel\u0000 9 ' } }) expect(ok.status).toBe(200) expect(Object.keys(ok.json).sort()).toEqual(['device_id', 'pc_name', 'token', 'version']) expect(ok.json.token).toMatch(/^amd_[A-Za-z0-9_-]{43}$/) expect(ok.json.device_id).toMatch(/^[0-9a-f-]{36}$/) expect(ok.json.pc_name).toBe('TEST-PC') expect(ok.json.version).toBe('1.0.0-test') expect(ok.headers['cache-control']).toBe('no-store') expect(t.proxy.devices()[0].name).toBe('Pixel 9') // Only the digest is handed to the store. expect(t.saved.at(-1)[0].tokenHash).toBe(crypto.createHash('sha256').update(ok.json.token).digest('hex')) expect(JSON.stringify(t.saved)).not.toContain('amd_') expect(t.ended).toEqual(['claimed']) await t.proxy.stop() }) it('the same code is 410 on second use (consumed before the token is minted)', async () => { const t = await startProxy() const { code } = await t.pair() const again = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) expect([again.status, again.json]).toEqual([410, { error: 'pairing_closed' }]) expect(t.proxy.devices()).toHaveLength(1) await t.proxy.stop() }) it('the fifth wrong attempt locks pairing; the right code is then 410 too', async () => { const t = await startProxy() const { code } = t.proxy.beginPairing() for (let i = 0; i < 4; i++) { expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } })).status).toBe(401) } const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) expect([fifth.status, fifth.json]).toEqual([410, { error: 'pairing_locked' }]) const right = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) expect([right.status, right.json]).toEqual([410, { error: 'pairing_closed' }]) expect(t.proxy.devices()).toHaveLength(0) expect(t.ended).toEqual(['locked']) await t.proxy.stop() }) it('an expired code is 410 pairing_closed', async () => { const t = await startProxy() const { code, expiresAt } = t.proxy.beginPairing() expect(expiresAt - t.clock.now).toBe(5 * 60_000) t.clock.now += 5 * 60_000 const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) expect(t.ended).toEqual(['expired']) await t.proxy.stop() }) it('malformed bodies are 400 invalid_request and do not burn an attempt; > 4 KiB is 413', async () => { const t = await startProxy() const { code } = t.proxy.beginPairing() for (const rawBody of ['{not json', '[]', '{"code":12345678,"device_name":"x"}', `{"code":"${code}"}`, `{"code":"${code}","device_name":""}`, JSON.stringify({ code, device_name: 'n'.repeat(61) })]) { const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody }) expect([r.status, r.json]).toEqual([400, { error: 'invalid_request' }]) } const big = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) }) }) expect(big.status).toBe(413) expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status).toBe(200) await t.proxy.stop() }) }) describe('LAN proxy: REST', () => { it('root and everything outside /api/ is 404 and never carries the backend session token', async () => { const t = await startProxy() const { token } = await t.pair() for (const path of ['/', '/index.html', '/api', '/assets/app.js', '/aiturk/pair/other', '/?token=' + token]) { const r = await call(t.port, 'GET', path, { ca: t.ca, token }) expect([path, r.status]).toEqual([path, 404]) expect(r.text).not.toContain(SESSION) expect(r.json).toEqual({ error: 'not_found' }) } await t.proxy.stop() }) it('no token, a wrong token and the backend’s REAL token are all 401 — and never resolve (start) a backend', async () => { const t = await startProxy() await t.pair() resolveCalls = 0 const attempts: [string, CallOptions][] = [ ['/api/sessions', {}], ['/api/status', {}], // public on the backend, still closed here ['/api/sessions', { token: 'amd_wrong' }], ['/api/sessions', { token: `amd_${'A'.repeat(43)}` }], ['/api/sessions', { token: SESSION }], ['/api/sessions', { headerToken: SESSION }], [`/api/files/download?token=${SESSION}`, {}], ['/api/env', { token: SESSION }] ] for (const [path, options] of attempts) { const r = await call(t.port, 'GET', path, { ca: t.ca, ...options }) expect([path, r.status, r.json]).toEqual([path, 401, { error: 'unauthorized' }]) } expect(resolveCalls).toBe(0) await t.proxy.stop() }) it('a valid device token is forwarded with the real session token; the backend never sees the device token, Origin or Cookie', async () => { const t = await startProxy() const { token } = await t.pair() for (const options of [{ token }, { headerToken: token }]) { seen.length = 0 const r = await call(t.port, 'GET', '/api/sessions?limit=20', { ca: t.ca, ...options, headers: { 'x-forwarded-for': '10.0.0.9' } }) expect(r.status).toBe(200) expect(r.json).toEqual({ ok: true, path: '/api/sessions' }) expect(seen).toHaveLength(1) expect(seen[0].host).toBe(`127.0.0.1:${backendPort}`) expect(seen[0].path).toBe('/api/sessions?limit=20') expect(seen[0].raw).toContain(SESSION) expect(seen[0].raw).not.toContain('amd_') expect(seen[0].raw).not.toContain('evil') expect(seen[0].raw).not.toContain('10.0.0.9') expect(r.headers['set-cookie']).toBeUndefined() expect(r.text).not.toContain(SESSION) } const post = await call(t.port, 'POST', '/api/sessions', { ca: t.ca, token, body: { title: 'x' } }) expect(post.status).toBe(200) expect(seen.at(-1)?.method).toBe('POST') await t.proxy.stop() }) it('?token= is swapped for the backend token (download link form)', async () => { const t = await startProxy() const { token } = await t.pair() seen.length = 0 const r = await call(t.port, 'GET', `/api/files/download?path=a.png&token=${token}`, { ca: t.ca }) expect(r.status).toBe(200) expect(seen[0].path).toContain(`token=${SESSION}`) expect(seen[0].path).not.toContain('amd_') await t.proxy.stop() }) it('forbidden endpoints are 403 before the backend is touched; allowed media and reads are 200', async () => { const t = await startProxy() const { token } = await t.pair() const forbidden: [string, string][] = [ ['GET', '/api/env'], ['PUT', '/api/env'], ['POST', '/api/env/reveal'], ['GET', '/api/config/raw'], ['PUT', '/api/config/raw'], ['POST', '/api/ops/doctor'], ['GET', '/api/ops/backup/download?archive=x'], ['POST', '/api/hermes/update'], ['GET', '/api/hermes/update/check'], ['GET', '/api/credentials/pool'], ['POST', '/api/credentials/pool'], ['POST', '/api/providers/custom-endpoints'], ['DELETE', '/api/providers/oauth/x'], ['POST', '/api/mcp/servers'], ['POST', '/api/mcp/catalog/install'], ['POST', '/api/webhooks'], ['PUT', '/api/config'], ['POST', '/api/config/model'], ['DELETE', '/api/webhooks/x'], ['POST', '/api/dashboard/agent-plugins/install'], ['DELETE', '/api/dashboard/agent-plugins/x'], ['GET', '/api/files'], ['DELETE', '/api/files?path=x'], ['GET', '/api/files/read?path=x'], ['POST', '/api/files/upload'], ['POST', '/api/files/download'], // encodings the backend would decode back to a forbidden route ['GET', '/api/%65nv'], ['GET', '/api/ENV'], ['GET', '/api/files%2Fread?path=x'], ['GET', '/api/sessions/../env'] ] seen.length = 0 resolveCalls = 0 for (const [method, path] of forbidden) { const r = await call(t.port, method, path, { ca: t.ca, token, ...(method === 'GET' || method === 'DELETE' ? {} : { body: {} }) }) expect([method, path, r.status, r.json]).toEqual([method, path, 403, { error: 'forbidden' }]) } expect(seen).toHaveLength(0) expect(resolveCalls).toBe(0) for (const path of ['/api/%2e%2e%2fapi/env/x%00', '/api//env', '/api/a%5Cb', '/api/x%2525']) { expect([path, (await call(t.port, 'GET', path, { ca: t.ca, token })).status]).toEqual([path, 400]) } const allowed: [string, string][] = [ ['GET', '/api/files/download?path=x'], ['GET', '/api/files/stream?path=x'], ['GET', '/api/providers/custom-endpoints'], ['GET', '/api/mcp/servers'], ['GET', '/api/webhooks'], ['GET', '/api/config'], ['GET', '/api/sessions'] ] for (const [method, path] of allowed) { expect([path, (await call(t.port, method, path, { ca: t.ca, token })).status]).toEqual([path, 200]) } await t.proxy.stop() }) it('refuses when the backend cannot be resolved locally (remote mode / not loopback) without leaking why', async () => { const remote = await startProxy({ resolveBackend: async () => { throw new Error(`remote gateway secret ${SESSION}`) } }) const a = await remote.pair() const r = await call(remote.port, 'GET', '/api/sessions', { ca: remote.ca, token: a.token }) expect([r.status, r.json]).toEqual([503, { error: 'backend_unavailable' }]) expect((await wsOpen(`wss://127.0.0.1:${remote.port}/api/ws?token=${a.token}`, remote.ca)).status).toBe(503) await remote.proxy.stop() const elsewhere = await startProxy({ resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION }) }) const b = await elsewhere.pair() expect((await call(elsewhere.port, 'GET', '/api/sessions', { ca: elsewhere.ca, token: b.token })).status).toBe(503) await elsewhere.proxy.stop() }) }) describe('LAN proxy: WebSocket', () => { it('no token / wrong token / backend token are 401; /api/pty and /api/console are 403 even with a valid token', async () => { const t = await startProxy() const { token } = await t.pair() const base = `wss://127.0.0.1:${t.port}` resolveCalls = 0 expect((await wsOpen(`${base}/api/ws`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/ws?token=amd_wrong`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/ws?token=${SESSION}`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/pty`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/pty?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/api/console?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/api/ws/extra?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/api/%70ty?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/?token=${token}`, t.ca)).status).toBe(404) expect(resolveCalls).toBe(0) await t.proxy.stop() }) it('/api/ws and the kanban events path open with the device token (Origin and Cookie dropped) and carry frames', async () => { const t = await startProxy() const { token, deviceId } = await t.pair() for (const path of ['/api/ws', '/api/plugins/kanban/events']) { const r = await wsOpen(`wss://127.0.0.1:${t.port}${path}?token=${token}`, t.ca, { origin: 'http://evil.example', headers: { cookie: 'session=evil' } }) expect([path, r.open]).toEqual([path, true]) const answer = await new Promise(resolve => { r.ws?.once('message', data => resolve(JSON.parse(String(data)))) r.ws?.send(JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'gateway.ping' })) }) expect(answer).toEqual({ jsonrpc: '2.0', result: { ok: true }, id: 7 }) expect(t.proxy.devices().find(device => device.id === deviceId)?.live).toBe(1) const closed = new Promise(resolve => r.ws?.once('close', resolve)) r.ws?.close() await closed await until(() => t.proxy.devices()[0].live === 0) } await t.proxy.stop() }) it('revoking a device closes its open WebSocket and turns its token 401 (REST and WS); other devices keep working', async () => { const t = await startProxy() const first = await t.pair('Telefon 1') const second = await t.pair('Telefon 2') const live = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${first.token}`, t.ca) const other = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${second.token}`, t.ca) expect([live.open, other.open]).toEqual([true, true]) const closed = new Promise(resolve => live.ws?.once('close', resolve)) expect(await t.proxy.revoke(first.deviceId)).toBe(true) await closed expect((await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: first.token })).status).toBe(401) expect((await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${first.token}`, t.ca)).status).toBe(401) expect(t.proxy.devices().map(device => device.name)).toEqual(['Telefon 2']) expect(t.saved.at(-1)).toHaveLength(1) expect((await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: second.token })).status).toBe(200) expect(other.ws?.readyState).toBe(WebSocket.OPEN) expect(await t.proxy.revoke(first.deviceId)).toBe(false) await t.proxy.stop() }) }) describe('LAN proxy: TLS, listening and logs', () => { it('serves exactly the pinned certificate; a client using the system CAs rejects it; plain HTTP does not work', async () => { const t = await startProxy() const peer = await new Promise((resolve, reject) => { const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false }, res => { resolve(crypto.createHash('sha256').update((res.socket as any).getPeerCertificate(true).raw).digest('base64url')) res.resume() }) req.on('error', reject) req.end() }) expect(peer).toBe(t.identity.fingerprint) expect(peer).not.toBe(createSelfSignedCertificate().fingerprint) // A phone pinned to ANOTHER certificate must refuse this server. await expect(call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert })).rejects.toThrow() const systemCa = await new Promise(resolve => { const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () => resolve('accepted')) req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) req.end() }) expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/) const plain = await new Promise(resolve => { const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res => resolve(String(res.statusCode))) req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code || 'error'))) req.end() }) expect(plain).not.toBe('200') await t.proxy.stop() }) it('after stop() nothing listens (ECONNREFUSED) and open sockets are closed', async () => { const t = await startProxy() const { token } = await t.pair() const live = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}`, t.ca) const closed = new Promise(resolve => live.ws?.once('close', resolve)) expect(await connectOutcome(t.port)).toBe('open') await t.proxy.stop() await closed expect(await connectOutcome(t.port)).toBe('ECONNREFUSED') expect(t.proxy.listening).toBe(false) }) it('logs method + path + status only: no device token, pairing code, session token, query or body', async () => { const t = await startProxy() const { code, token } = await t.pair('Gizli Telefon') await call(t.port, 'GET', `/api/sessions?token=${token}&q=sorgu-metni`, { ca: t.ca }) await call(t.port, 'GET', `/api/sessions/${token}`, { ca: t.ca, token }) await call(t.port, 'POST', '/api/sessions', { ca: t.ca, token, body: { secret: 'govde-metni' } }) await call(t.port, 'GET', '/api/env', { ca: t.ca, token }) await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: SESSION }) await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) await wsOpen(`wss://127.0.0.1:${t.port}/api/pty?token=${token}`, t.ca) const text = t.logs.join('\n') expect(t.logs).toContain('POST /aiturk/pair/claim 200') expect(t.logs).toContain('GET /api/sessions 200') expect(t.logs).toContain('GET /api/env 403') expect(t.logs).toContain('GET /api/sessions 401') expect(t.logs).toContain('GET /api/pty 403') expect(text).not.toContain('amd_') expect(text).not.toContain(token.slice(4)) expect(text).not.toContain(code) expect(text).not.toContain(SESSION) expect(text).not.toContain('sorgu-metni') expect(text).not.toContain('govde-metni') expect(text).not.toContain('Gizli Telefon') await t.proxy.stop() }) }) // ── controller ────────────────────────────────────────────────────────────── function linkHarness(over: Record = {}) { const vault = { value: { accountToken: 'eski-alan' } as Record } const store = { value: null as any, saves: 0 } const firewallCalls: string[] = [] const firewallResults: any[] = over.firewallResults ?? [] const fetchCalls: { url: string; init: any }[] = [] const events: MobileLinkEvent[] = [] const logs: string[] = [] const state = { remote: false, offerStatus: over.offerStatus ?? 201 } const deps = { 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) store.saves++ } }, resolveBackend, isRemoteMode: () => state.remote, firewall: { addRule: async (port: number) => { firewallCalls.push(`add:${port}`) return firewallResults.shift() ?? { ok: true } }, removeRule: async () => { firewallCalls.push('remove') return { ok: true } } }, fetch: async (url: string, init: any) => { fetchCalls.push({ url, init }) if (state.offerStatus === 'throw') { throw new Error('network down') } if (url.endsWith('/offers')) { return { status: state.offerStatus, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 }) } } return { status: 200, json: async () => ({ cancelled: true }) } }, addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'], version: '1.0.0-test', pcName: 'TEST-PC', platform: 'win32', listenHost: '127.0.0.1', ports: [0], log: (line: string) => logs.push(line) } const make = () => { const link = createMobileLink(deps as any) link.onEvent(event => events.push(event)) return link } return { link: make(), make, vault, store, firewallCalls, fetchCalls, events, logs, state } } describe('mobile link controller', () => { it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => { const h = linkHarness() expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, port: null, pairing: null, devices: [] }) const enabled = await h.link.enable() const port = enabled.port as number expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'ok', remote: false }) expect(h.firewallCalls).toEqual([`add:${port}`]) expect(await connectOutcome(port)).toBe('ECONNREFUSED') const pairing = await h.link.pairBegin() expect(pairing.listening).toBe(true) expect(await connectOutcome(port)).toBe('open') const cancelled = await h.link.pairCancel() expect(cancelled).toMatchObject({ listening: false, pairing: null }) expect(await connectOutcome(port)).toBe('ECONNREFUSED') // The firewall is asked once per port, not on every enable. await h.link.enable() expect(h.firewallCalls).toEqual([`add:${port}`]) await h.link.stop() }) it('pairing: QR payload, rendezvous body without the raw code, claim over pinned TLS, "paired" event, cancel of the offer', async () => { const h = linkHarness() await h.link.enable() const state = await h.link.pairBegin() const pairing = state.pairing! const port = state.port as number const fp = h.vault.value.lanTls.fingerprint expect(pairing.code).toMatch(new RegExp(`^[${PAIR_CODE_ALPHABET}]{8}$`)) expect(pairing.qr).toBe(`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${pairing.code}`) expect(fp).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(pairing.codePath).toBe('pending') await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) const offer = h.fetchCalls[0] const body = JSON.parse(offer.init.body) expect(offer.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/offers') expect(offer.init.method).toBe('POST') expect(offer.init.redirect).toBe('error') expect(offer.init.signal).toBeInstanceOf(AbortSignal) expect(body).toEqual({ lookup: crypto.createHash('sha256').update(`aiturk-mobile-pair-v1:${pairing.code}`).digest('hex'), urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`], fp }) expect(offer.init.body).not.toContain(pairing.code) expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code) const ca = h.vault.value.lanTls.cert const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: pairing.code, device_name: 'Pixel 9' } }) expect(claim.status).toBe(200) await until(() => h.events.some(event => event.type === 'paired')) const paired = h.events.find(event => event.type === 'paired') as Extract expect(paired.name).toBe('Pixel 9') expect(paired.state).toMatchObject({ listening: true, pairing: null }) expect(paired.state.devices.map(device => device.name)).toEqual(['Pixel 9']) // Disk: digest only; the private key only in the vault; old vault fields kept. const disk = JSON.stringify(h.store.value) expect(disk).not.toContain('amd_') expect(disk).not.toContain(claim.json.token.slice(4)) expect(disk).not.toContain('PRIVATE KEY') expect(h.store.value.devices[0].tokenHash).toBe(crypto.createHash('sha256').update(claim.json.token).digest('hex')) expect(h.vault.value.accountToken).toBe('eski-alan') // The gateway record is cancelled with its own token once the code is spent. await until(() => h.fetchCalls.some(entry => entry.url.endsWith('/cancel'))) const cancel = h.fetchCalls.find(entry => entry.url.endsWith('/cancel'))! expect(cancel.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/cancel') expect(cancel.init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`) expect(cancel.init.redirect).toBe('error') // Paired phone works; still listening after pairing ended. expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200) // Nothing secret reached the log or any event. const text = h.logs.join('\n') expect(text).not.toContain('amd_') expect(text).not.toContain(pairing.code) expect(JSON.stringify(h.events.filter(event => event.type === 'paired'))).not.toContain(pairing.code) // Revoke through the controller: token dies, and with no phone left the socket closes. const live = await wsOpen(`wss://127.0.0.1:${port}/api/ws?token=${claim.json.token}`, ca) const closed = new Promise(resolve => live.ws?.once('close', resolve)) const after = await h.link.deviceRevoke(claim.json.device_id) await closed expect(after).toMatchObject({ devices: [], listening: false }) expect(await connectOutcome(port)).toBe('ECONNREFUSED') await expect(h.link.deviceRevoke('not-a-uuid')).rejects.toThrow('aiturk-mobile:invalid_request') await h.link.stop() }) it('when the gateway is unreachable the QR path still works and the code path is marked unavailable', async () => { for (const offerStatus of ['throw', 429, 409]) { const h = linkHarness({ offerStatus }) await h.link.enable() const state = await h.link.pairBegin() await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) const claim = await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } }) expect(claim.status).toBe(200) expect(h.fetchCalls.some(entry => entry.url.endsWith('/cancel'))).toBe(false) await h.link.stop() } }) it('disable stops listening and keeps the phones; the next app start resumes; quitting closes the socket', async () => { const h = linkHarness() await h.link.enable() const state = await h.link.pairBegin() const port = state.port as number const ca = h.vault.value.lanTls.cert const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } }) await until(() => h.events.some(event => event.type === 'paired')) const off = await h.link.disable() expect(off).toMatchObject({ enabled: false, listening: false }) expect(off.devices).toHaveLength(1) expect(await connectOutcome(port)).toBe('ECONNREFUSED') expect(h.firewallCalls).toEqual([`add:${port}`]) // disabling never removes the rule await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:not_enabled') // Disabled at app start: no socket. const restartedOff = h.make() expect(await restartedOff.start()).toMatchObject({ enabled: false, listening: false }) expect(await connectOutcome(port)).toBe('ECONNREFUSED') await restartedOff.enable() await restartedOff.stop() // Enabled with a paired phone at app start: listens on the stored port, same certificate, same token. const restarted = h.make() const resumed = await restarted.start() expect(resumed).toMatchObject({ enabled: true, listening: true, port, firewall: 'ok' }) expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200) await restarted.stop() expect(await connectOutcome(port)).toBe('ECONNREFUSED') }) it('remote-gateway mode refuses the feature and never listens', async () => { const h = linkHarness() h.state.remote = true await expect(h.link.enable()).rejects.toBeInstanceOf(MobileLinkError) await expect(h.link.enable()).rejects.toThrow('aiturk-mobile:remote_mode') expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, remote: true }) expect(h.firewallCalls).toEqual([]) h.state.remote = false await h.link.enable() h.state.remote = true await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:remote_mode') expect((await h.link.start()).listening).toBe(false) await h.link.stop() }) it('a refused UAC prompt leaves a clear state and "Yeniden dene" adds the rule', async () => { const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] }) const state = await h.link.enable() expect(state).toMatchObject({ enabled: true, firewall: 'denied' }) expect((await h.link.firewallRetry()).firewall).toBe('failed') expect((await h.link.firewallRetry()).firewall).toBe('ok') expect(h.firewallCalls).toHaveLength(3) expect((await h.link.firewallRetry()).firewall).toBe('ok') expect(h.firewallCalls).toHaveLength(3) expect((await h.link.firewallRemove()).firewall).toBe('needed') expect(h.firewallCalls.at(-1)).toBe('remove') await h.link.stop() }) it('without a usable LAN address pairing is refused; a failing vault is reported by code only', async () => { const h = linkHarness({ addresses: ['8.8.8.8', '169.254.3.4'] }) await h.link.enable() await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address') await h.link.stop() const broken = createMobileLink({ vault: { load: async () => { throw new Error(`Windows kasası ${SESSION}`) }, save: async () => undefined }, store: { load: async () => null, save: async () => undefined }, resolveBackend, isRemoteMode: () => false, firewall: { addRule: async () => ({ ok: true }), removeRule: async () => ({ ok: true }) }, fetch: async () => ({ status: 500, json: async () => ({}) }), addresses: async () => [], version: 'x', pcName: 'x', platform: 'win32', listenHost: '127.0.0.1', ports: [0] } as any) await expect(broken.enable()).rejects.toThrow(/^aiturk-mobile:vault_unavailable$/) }) it('a regenerated certificate drops phones paired against the old one', async () => { const h = linkHarness() await h.link.enable() const state = await h.link.pairBegin() await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } }) await until(() => h.events.some(event => event.type === 'paired')) await h.link.stop() delete h.vault.value.lanTls const restarted = h.make() const resumed = await restarted.start() expect(resumed.devices).toEqual([]) expect(resumed.listening).toBe(false) await restarted.stop() }) }) describe('pure helpers', () => { it('pairing code: alphabet, normalisation, display form, lookup digest', () => { for (let i = 0; i < 200; i++) { expect(generatePairCode()).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/) } expect(generatePairCode(() => Buffer.from([0, 31, 32, 63, 255, 8, 9, 10]))).toBe('A9A99JKL') expect(normalizePairCode(' abcd-efgh ')).toBe('ABCDEFGH') expect(normalizePairCode(12345678)).toBe('') expect(formatPairCode('ABCDEFGH')).toBe('ABCD-EFGH') expect(pairLookup('abcd-efgh')).toBe(crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex')) }) it('address candidates: private IPv4 only, default route first, virtual adapters dropped, at most four', () => { expect(['10.1.2.3', '172.16.0.1', '172.31.255.254', '192.168.0.1'].every(isPrivateIpv4)).toBe(true) expect(['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some(isPrivateIpv4)).toBe(false) const v4 = (address: string, internal = false) => [{ address, family: 'IPv4', internal }] const interfaces = { 'vEthernet (WSL (Hyper-V firewall))': v4('172.28.0.1'), 'VirtualBox Host-Only Network': v4('192.168.56.1'), 'VMware Network Adapter VMnet8': v4('192.168.80.1'), 'Loopback Pseudo-Interface 1': v4('127.0.0.1', true), Ethernet: [{ address: 'fe80::1', family: 'IPv6', internal: false }, ...v4('10.0.0.5')], 'Wi-Fi': v4('192.168.1.20'), Mobile: v4('100.70.1.2'), 'Ethernet 2': v4('192.168.2.2'), 'Ethernet 3': v4('192.168.3.2'), 'Ethernet 4': v4('192.168.4.2') } expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual(['192.168.1.20', '10.0.0.5', '192.168.2.2', '192.168.3.2']) expect(selectLanAddresses(interfaces, null)[0]).toBe('10.0.0.5') expect(selectLanAddresses({ 'vEthernet (Default Switch)': v4('172.20.0.1') }, null)).toEqual([]) expect(() => buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toThrow( 'aiturk-mobile:no_lan_address' ) expect(buildPairQrPayload({ addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toBe( `aiturk://pair?v=1&u=10.0.0.1:47321&u=10.0.0.2:47321&u=10.0.0.3:47321&u=10.0.0.4:47321&fp=${'f'.repeat(43)}&c=ABCDEFGH` ) }) it('forbidden list', () => { expect(isPhoneForbidden('GET', '/api/providers/oauth')).toBe(false) expect(isPhoneForbidden('PATCH', '/api/providers/oauth')).toBe(true) expect(isPhoneForbidden('HEAD', '/api/files/stream')).toBe(false) expect(isPhoneForbidden('GET', '/api/files/stream/x')).toBe(true) expect(isPhoneForbidden('GET', '/api/environment')).toBe(true) expect(isPhoneForbidden('GET', '/api/sessions')).toBe(false) expect(isPhoneForbidden('POST', '/api/sessions')).toBe(false) }) it('firewall command: fixed arguments, numeric port only, no shell; exit codes map to ok / denied / failed', async () => { const add = firewallCommand('add', 47321, 'C:\\Windows') expect(add.file).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') expect(add.args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']) expect(add.args[5]).toContain("Start-Process -FilePath 'C:\\Windows\\System32\\netsh.exe' -Verb RunAs") expect(add.args[5]).toContain( `-ArgumentList 'advfirewall firewall add rule name="AITURK IDE Telefon Baglantisi" dir=in action=allow protocol=TCP localport=47321 profile=private,public'` ) expect(firewallCommand('remove', 47321).args[5]).toContain(`delete rule name="AITURK IDE Telefon Baglantisi"'`) expect(firewallCommand('add', 47321, "C:\\x'; calc; '").file.startsWith('C:\\Windows\\')).toBe(true) for (const bad of [0, 80, 70000, 1.5, Number.NaN, '47321; calc' as any]) { expect(() => firewallCommand('add', bad)).toThrow('aiturk-mobile:invalid_port') } for (const [exit, expected] of [[0, { ok: true }], [77, { ok: false, reason: 'denied' }], [1, { ok: false, reason: 'failed' }]] as const) { const spawned: any[] = [] const firewall = createWindowsFirewall({ platform: 'win32', env: { SystemRoot: 'C:\\Windows' }, spawn: (file, args, options) => { spawned.push({ file, args, options }) return { on: (event: string, listener: (...values: any[]) => void) => { if (event === 'exit') { setTimeout(() => listener(exit), 0) } } } } }) expect(await firewall.addRule(47321)).toEqual(expected) expect(spawned[0].options).toMatchObject({ shell: false, windowsHide: true }) } const never = createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => { throw new Error('must not spawn') } }) expect(await never.addRule(47321)).toEqual({ ok: true }) }) it('stored state is validated on load (bad rows dropped, no token field survives)', () => { const hash = 'a'.repeat(64) const id = '0f8fad5b-d9cb-469f-a165-70867728950e' expect( parseMobileLinkStored({ enabled: 'yes', port: 99999, devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null] }) ).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }] }) expect(parseMobileLinkStored(null)).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [] }) }) })