/**
* 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: [] })
})
})