Files
aiturk-hermes-ide/apps/desktop/electron/mobile-lan-proxy.test.ts
T
yilsemandClaude Opus 5.5 03d3803dfc feat(aiturk): phone link for AITURK Mobil (1.0.0-beta.15)
Settings gets a Phone link window: when enabled, the IDE main process runs a
small TLS proxy on the local network (self-signed certificate, pinned by its
fingerprint) in front of the loopback backend and pairs a phone by QR code
or an 8-character code. Each phone gets its own revocable token; the backend
session token never leaves the computer. The root page, terminal sockets and
secret or settings-changing endpoints are not reachable from a phone. The
old WebRTC pairing window is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-04 06:02:02 +03:00

1149 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, `<script>window.__HERMES_SESSION_TOKEN__="${SESSION}";</script>`, '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<void>(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<string, string>
}
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<string, unknown> = {}) {
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<string>(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<string, unknown> = {}) {
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 <b>9</b> ' } })
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 <b>9</b>')
// 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=<device 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<string>((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<string>(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<string>(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<string, any> = {}) {
const vault = { value: { accountToken: 'eski-alan' } as Record<string, any> }
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<string, any>) => {
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<MobileLinkEvent, { type: 'paired' }>
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: [] })
})
})