/** * mobile-lan-proxy.ts * * "Telefon bağlantısı": lets the AITURK Mobil Android app reach this IDE's * local backend from the same Wi-Fi / LAN. The backend itself keeps listening * on 127.0.0.1 only; this module adds a small TLS reverse proxy in the Electron * main process plus the pairing session that hands a phone its own token. * * Contract: workspace/aiturk-mobil-eslestirme-spec.md (v1). The Android app and * the gateway rendezvous endpoints are written against the same document, so * paths, field names and error codes here must not drift. * * Security contract (do not relax): * - `/` and everything outside `/api/` is answered here with 404 and never * forwarded: the backend's root page embeds its session token. * - A phone only ever knows its own device token (`amd_…`). The backend's * real session token is added here and never sent to the phone. Only the * SHA-256 of a device token is kept (memory and disk). * - Authentication happens BEFORE the backend is resolved, so an * unauthenticated LAN request can never start a backend. * - Tokens, pairing codes and bodies are never logged or put into error * messages; the log line is method + path (no query) + status. * - The rendezvous record holds no secret: the raw code never leaves this * machine, only hex(SHA-256("aiturk-mobile-pair-v1:" + CODE)). * * All I/O that matters for tests is injected (backend resolver, clock, * randomness, store, vault, firewall, fetch, address discovery); node's * https/net modules are used for real so the tests exercise real TLS sockets. */ import crypto from 'node:crypto' import dgram from 'node:dgram' import http from 'node:http' import https from 'node:https' import net from 'node:net' import type { Duplex } from 'node:stream' import { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert' export const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' export const PAIR_CODE_LENGTH = 8 export const PAIR_CODE_TTL_MS = 5 * 60_000 export const PAIR_MAX_WRONG_ATTEMPTS = 5 export const PAIR_CLAIM_MAX_BODY_BYTES = 4096 export const MOBILE_LAN_DEFAULT_PORT = 47321 export const MOBILE_LAN_LAST_PORT = 47340 export const MOBILE_LAN_MAX_URLS = 4 export const MOBILE_PAIR_ORIGIN = 'https://turkservis.online' export const MOBILE_PAIR_FETCH_TIMEOUT_MS = 20_000 export const MOBILE_FIREWALL_RULE_NAME = 'AITURK IDE Telefon Baglantisi' export const MOBILE_LAN_WS_PATHS: readonly string[] = ['/api/ws', '/api/plugins/kanban/events'] const DEVICE_TOKEN_RE = /^amd_[A-Za-z0-9_-]{43}$/ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const CANCEL_TOKEN_RE = /^tsmp_[A-Za-z0-9_-]{43}$/ const HASH_RE = /^[0-9a-f]{64}$/ const LAST_SEEN_PERSIST_MS = 60_000 const sha256Hex = (value: string) => crypto.createHash('sha256').update(value).digest('hex') // ── Pairing code ──────────────────────────────────────────────────────────── /** Upper-case and drop spaces / dashes, as the contract's comparison rule says. */ export function normalizePairCode(value: unknown): string { return typeof value === 'string' ? value.toUpperCase().replace(/[\s-]/g, '') : '' } export function generatePairCode(randomBytes: (size: number) => Buffer = crypto.randomBytes): string { // 256 % 32 === 0, so masking a byte to 5 bits is uniform over the alphabet. const bytes = randomBytes(PAIR_CODE_LENGTH) let code = '' for (let i = 0; i < PAIR_CODE_LENGTH; i++) { code += PAIR_CODE_ALPHABET[bytes[i] & 31] } return code } /** Display form only: ABCD-EFGH. */ export function formatPairCode(code: string): string { return `${code.slice(0, 4)}-${code.slice(4)}` } /** hex(SHA-256("aiturk-mobile-pair-v1:" + CODE)) — the only thing the gateway sees. */ export function pairLookup(code: string): string { return sha256Hex(`aiturk-mobile-pair-v1:${normalizePairCode(code)}`) } // ── Local address candidates ──────────────────────────────────────────────── export function isPrivateIpv4(address: string): boolean { const parts = String(address).split('.') if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part) || Number(part) > 255)) { return false } const [a, b] = parts.map(Number) return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) } const VIRTUAL_ADAPTER_RE = /vethernet|virtualbox|vmware|wsl|hyper-v|loopback|docker|tailscale|zerotier|wireguard|bluetooth|vpn|\b(?:tap|tun)\d*\b/i export interface LanInterfaceInfo { address: string family: string | number internal: boolean } /** * Pick up to `max` private IPv4 addresses a phone on the same LAN could reach. * The default-route interface goes first; virtual adapters are dropped (unless * nothing else is left and the default route itself is the only candidate). */ export function selectLanAddresses( interfaces: Record, defaultRoute: null | string, max = MOBILE_LAN_MAX_URLS ): string[] { const rows: { address: string; primary: boolean; virtual: boolean }[] = [] for (const [name, list] of Object.entries(interfaces ?? {})) { for (const item of list ?? []) { const ipv4 = item.family === 'IPv4' || item.family === 4 if (ipv4 && !item.internal && isPrivateIpv4(item.address) && !rows.some(row => row.address === item.address)) { rows.push({ address: item.address, primary: item.address === defaultRoute, virtual: VIRTUAL_ADAPTER_RE.test(name) }) } } } const real = rows.filter(row => !row.virtual).sort((a, b) => Number(b.primary) - Number(a.primary)) const chosen = real.length ? real : rows.filter(row => row.primary) return chosen.slice(0, max).map(row => row.address) } /** * Local address of the default route. Connecting a UDP socket only selects a * route; no packet is sent. */ export function defaultRouteAddress(): Promise { return new Promise(resolve => { let socket: dgram.Socket try { socket = dgram.createSocket('udp4') } catch { resolve(null) return } const finish = (value: null | string) => { try { socket.close() } catch { // already closed } resolve(value) } socket.once('error', () => finish(null)) try { socket.connect(53, '1.1.1.1', () => { let address: null | string = null try { address = socket.address().address } catch { address = null } finish(address) }) } catch { finish(null) } }) } // ── QR payload ────────────────────────────────────────────────────────────── /** `aiturk://pair?v=1&u=[&u=…]&fp=<43>&c=` (contract §2). */ export function buildPairQrPayload(input: { addresses: string[]; port: number; fingerprint: string; code: string }): string { const hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) if (!hosts.length || !Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { throw new MobileLinkError('no_lan_address') } // ip:port, the fingerprint (base64url) and the code need no escaping in a query. return `aiturk://pair?v=1&${hosts.map(host => `u=${host}:${input.port}`).join('&')}&fp=${input.fingerprint}&c=${input.code}` } // ── Gateway rendezvous client (contract §3; server side lives in the gateway) ─ export interface PairFetchResponse { status: number json: () => Promise } export type PairFetch = (url: string, init: Record) => Promise export interface PairOffer { offerId: string cancelToken: string } function pairFetchInit(body: unknown, headers: Record = {}) { return { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(MOBILE_PAIR_FETCH_TIMEOUT_MS) } } /** * Publish the rendezvous record. Returns null on any failure — the QR path * keeps working and the UI shows "kodla bağlanma kullanılamıyor". */ export async function publishPairOffer( fetcher: PairFetch, input: { code: string; addresses: string[]; port: number; fingerprint: string } ): Promise { const urls = input.addresses .filter(isPrivateIpv4) .slice(0, MOBILE_LAN_MAX_URLS) .map(host => `https://${host}:${input.port}`) if (!urls.length) { return null } try { const response = await fetcher( `${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint }) ) if (response.status !== 201) { return null } const data = (await response.json()) as null | Record if (!data || typeof data.offerId !== 'string' || !UUID_RE.test(data.offerId)) { return null } if (typeof data.cancelToken !== 'string' || !CANCEL_TOKEN_RE.test(data.cancelToken)) { return null } return { offerId: data.offerId, cancelToken: data.cancelToken } } catch { return null } } /** Best effort; the record expires on its own after five minutes anyway. */ export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise { try { await fetcher( `${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/cancel`, pairFetchInit({}, { Authorization: `Bearer ${offer.cancelToken}` }) ) } catch { // nothing to do } } // ── Windows Firewall ──────────────────────────────────────────────────────── export interface FirewallResult { ok: boolean /** Set when `ok` is false. `denied` = the user refused the UAC prompt. */ reason?: 'denied' | 'failed' } export interface MobileFirewall { addRule: (port: number) => Promise removeRule: () => Promise } const UAC_DENIED_EXIT = 77 function assertPort(port: number): number { if (!Number.isInteger(port) || port < 1024 || port > 65535) { throw new MobileLinkError('invalid_port') } return port } /** * Fixed command lines for the inbound rule. One elevated `netsh` call through * `Start-Process -Verb RunAs` (a single UAC prompt). The only variable part is * the numeric port, validated above; nothing here comes from user input and no * shell is involved (the caller spawns PowerShell with an argument array). */ export function firewallCommand(action: 'add' | 'remove', port: number, systemRoot?: string) { const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows' const netshArgs = action === 'add' ? `advfirewall firewall add rule name="${MOBILE_FIREWALL_RULE_NAME}" dir=in action=allow protocol=TCP localport=${assertPort(port)} profile=private,public` : `advfirewall firewall delete rule name="${MOBILE_FIREWALL_RULE_NAME}"` const script = `try { $p = Start-Process -FilePath '${root}\\System32\\netsh.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru ` + `-ArgumentList '${netshArgs}'; exit $p.ExitCode } catch { exit ${UAC_DENIED_EXIT} }` return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script] } } export interface FirewallSpawn { (file: string, args: string[], options: Record): { on: (event: string, listener: (...args: any[]) => void) => unknown } } /** Real implementation; tests inject a fake `MobileFirewall` instead. */ export function createWindowsFirewall(deps: { spawn: FirewallSpawn platform: string env: Record }): MobileFirewall { const run = (action: 'add' | 'remove', port: number) => new Promise(resolve => { if (deps.platform !== 'win32') { resolve({ ok: true }) return } let settled = false const finish = (result: FirewallResult) => { if (!settled) { settled = true resolve(result) } } try { const command = firewallCommand(action, port, deps.env.SystemRoot) const child = deps.spawn(command.file, command.args, { shell: false, windowsHide: true, stdio: 'ignore' }) child.on('error', () => finish({ ok: false, reason: 'failed' })) child.on('exit', (code: null | number) => finish(code === 0 ? { ok: true } : { ok: false, reason: code === UAC_DENIED_EXIT ? 'denied' : 'failed' }) ) } catch { finish({ ok: false, reason: 'failed' }) } }) return { addRule: port => run('add', port), removeRule: () => run('remove', MOBILE_LAN_DEFAULT_PORT) } } // ── What a phone may not reach ────────────────────────────────────────────── type DenyRule = [prefix: RegExp, scope: 'all' | 'write'] /** * Contract §1 "Telefondan yasak". Matched case-insensitively against the * percent-DECODED path (the backend decodes before routing, so matching the * raw path would let `/api/%65nv` through). */ const DENY_RULES: readonly DenyRule[] = [ [/^\/api\/env/i, 'all'], [/^\/api\/config\/raw/i, 'all'], // Reading settings is fine; changing them from a phone is not. [/^\/api\/config(?:\/|$)/i, 'write'], [/^\/api\/ops(?:\/|$)/i, 'all'], [/^\/api\/hermes\/update/i, 'all'], [/^\/api\/credentials/i, 'all'], [/^\/api\/providers/i, 'write'], [/^\/api\/mcp(?:\/|$)/i, 'write'], [/^\/api\/webhooks/i, 'write'], // Plugin install / enable / disable / update / remove. [/^\/api\/dashboard\/agent-plugins/i, 'all'], [/^\/api\/files(?:\/|$)/i, 'all'] ] const FILES_READ_ALLOWED = new Set(['/api/files/download', '/api/files/stream']) export function isPhoneForbidden(method: string, decodedPath: string): boolean { const read = method === 'GET' || method === 'HEAD' if (read && FILES_READ_ALLOWED.has(decodedPath)) { return false } return DENY_RULES.some(([prefix, scope]) => prefix.test(decodedPath) && (scope === 'all' || !read)) } /** Decoded path, or null when the request target is something we refuse to interpret. */ function decodeRequestPath(pathname: string): null | string { let decoded: string try { decoded = decodeURIComponent(pathname) } catch { return null } // Control characters, backslashes, a second layer of encoding, empty and dot segments. // eslint-disable-next-line no-control-regex if (/[\x00-\x1f\x7f\\%]/.test(decoded) || decoded.includes('//') || /(?:^|\/)\.\.?(?:\/|$)/.test(decoded)) { return null } return decoded } // ── The proxy ─────────────────────────────────────────────────────────────── export interface MobileBackend { baseUrl: string token: string } export interface MobileDeviceRecord { id: string name: string /** hex SHA-256 of the device token; the token itself is never stored. */ tokenHash: string createdAt: number lastSeenAt: number } export interface MobileDeviceView { id: string name: string createdAt: number lastSeenAt: number /** Open connections right now (WebSocket or in-flight request). */ live: number } export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled' export interface LanProxyOptions { tls: { key: string; cert: string } /** Must resolve the LOCAL backend or throw (remote mode, backend down). */ resolveBackend: () => Promise version: string pcName: string devices?: MobileDeviceRecord[] now?: () => number randomBytes?: (size: number) => Buffer randomUUID?: () => string /** method + path + status only. */ log?: (line: string) => void /** Persist; awaited before a claim answers. Throwing fails the claim. */ onDevicesChanged?: (devices: MobileDeviceRecord[]) => Promise | void onPairingEnded?: (reason: PairingEndReason, device?: MobileDeviceRecord) => void codeTtlMs?: number maxWrongAttempts?: number } interface LiveDevice extends MobileDeviceRecord { sockets: Set<{ destroy: () => unknown }> persistedSeenAt: number } const DROPPED_REQUEST_HEADERS = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'authorization', 'x-hermes-session-token', 'cookie', 'origin', 'referer', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-prefix', 'x-real-ip', 'forwarded' ]) const DROPPED_RESPONSE_HEADERS = ['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'proxy-authenticate'] const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']) const STATUS_TEXT: Record = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' } export function createLanProxy(options: LanProxyOptions) { const now = options.now ?? Date.now const randomBytes = options.randomBytes ?? crypto.randomBytes const randomUUID = options.randomUUID ?? crypto.randomUUID const codeTtlMs = options.codeTtlMs ?? PAIR_CODE_TTL_MS const maxWrongAttempts = options.maxWrongAttempts ?? PAIR_MAX_WRONG_ATTEMPTS const devices = new Map() const sockets = new Set() let pairing: null | { codeHash: Buffer; expiresAt: number; attempts: number } = null let listeningPort: null | number = null for (const row of options.devices ?? []) { devices.set(row.tokenHash, { ...row, sockets: new Set(), persistedSeenAt: row.lastSeenAt }) } const records = (): MobileDeviceRecord[] => [...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ id, name, tokenHash, createdAt, lastSeenAt })) const log = (method: string | undefined, path: string, status: number) => { if (options.log) { // Path only (no query), bounded; a token-shaped segment is masked defensively. const safe = path.slice(0, 160).replace(/amd_[A-Za-z0-9_-]*/g, '[redacted]') options.log(`${String(method ?? '').slice(0, 10)} ${safe} ${status}`) } } const reply = (req: http.IncomingMessage, res: http.ServerResponse, path: string, status: number, body: unknown) => { log(req.method, path, status) if (res.headersSent) { res.destroy() return } res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }) res.end(JSON.stringify(body)) } function credential(req: http.IncomingMessage, url: URL): string { const auth = req.headers.authorization if (typeof auth === 'string' && /^bearer /i.test(auth)) { return auth.slice(7).trim() } const header = req.headers['x-hermes-session-token'] if (typeof header === 'string' && header) { return header } return url.searchParams.get('token') ?? '' } function authenticate(req: http.IncomingMessage, url: URL): LiveDevice | undefined { const token = credential(req, url) // Shape check first: the backend's own session token is not `amd_…`. if (!DEVICE_TOKEN_RE.test(token)) { return undefined } const device = devices.get(sha256Hex(token)) if (device) { device.lastSeenAt = now() if (device.lastSeenAt - device.persistedSeenAt >= LAST_SEEN_PERSIST_MS) { device.persistedSeenAt = device.lastSeenAt void Promise.resolve(options.onDevicesChanged?.(records())).catch(() => undefined) } } return device } async function localBackend(): Promise { try { const backend = await options.resolveBackend() const target = new URL(backend.baseUrl) // Never forward a phone to anything but the loopback backend. if (target.protocol !== 'http:' || !LOOPBACK_HOSTS.has(target.hostname) || !backend.token) { return null } return { target, token: backend.token } } catch { return null } } function endPairing(reason: PairingEndReason, device?: MobileDeviceRecord) { pairing = null options.onPairingEnded?.(reason, device) } function pairingOpen(): boolean { if (pairing && pairing.expiresAt <= now()) { endPairing('expired') } return pairing !== null } /** Body text, or null when it is larger than the limit. */ async function readClaimBody(req: http.IncomingMessage): Promise { if (Number(req.headers['content-length'] ?? 0) > PAIR_CLAIM_MAX_BODY_BYTES) { return null } const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { size += chunk.length if (size > PAIR_CLAIM_MAX_BODY_BYTES) { return null } chunks.push(chunk) } return Buffer.concat(chunks).toString('utf8') } async function claim(req: http.IncomingMessage, res: http.ServerResponse, path: string) { const body = await readClaimBody(req) if (body === null) { res.setHeader('connection', 'close') return reply(req, res, path, 413, { error: 'too_large' }) } let data: any try { data = JSON.parse(body) } catch { return reply(req, res, path, 400, { error: 'invalid_request' }) } // eslint-disable-next-line no-control-regex const name = typeof data?.device_name === 'string' ? data.device_name.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim() : '' if (!data || typeof data !== 'object' || typeof data.code !== 'string' || name.length < 1 || name.length > 60) { return reply(req, res, path, 400, { error: 'invalid_request' }) } if (!pairingOpen() || !pairing) { return reply(req, res, path, 410, { error: 'pairing_closed' }) } // Constant time: both sides are fixed-length digests. const offered = crypto.createHash('sha256').update(normalizePairCode(data.code)).digest() if (!crypto.timingSafeEqual(offered, pairing.codeHash)) { if (++pairing.attempts >= maxWrongAttempts) { endPairing('locked') return reply(req, res, path, 410, { error: 'pairing_locked' }) } return reply(req, res, path, 401, { error: 'wrong_code' }) } // Single use: consume first, then mint the token. pairing = null const token = `amd_${randomBytes(32).toString('base64url')}` const stamp = now() const device: LiveDevice = { id: randomUUID(), name, tokenHash: sha256Hex(token), createdAt: stamp, lastSeenAt: stamp, sockets: new Set(), persistedSeenAt: stamp } devices.set(device.tokenHash, device) try { await options.onDevicesChanged?.(records()) } catch { devices.delete(device.tokenHash) options.onPairingEnded?.('cancelled') return reply(req, res, path, 500, { error: 'internal' }) } options.onPairingEnded?.('claimed', records().find(row => row.id === device.id)) return reply(req, res, path, 200, { token, device_id: device.id, pc_name: options.pcName, version: options.version }) } function forward(req: http.IncomingMessage, res: http.ServerResponse, url: URL, path: string, device: LiveDevice, backend: { target: URL; token: string }) { const headers: Record = {} for (const [key, value] of Object.entries(req.headers)) { if (value !== undefined && !DROPPED_REQUEST_HEADERS.has(key)) { headers[key] = value } } headers.host = backend.target.host headers['x-hermes-session-token'] = backend.token if (url.searchParams.has('token')) { url.searchParams.set('token', backend.token) } const clientSocket = req.socket device.sockets.add(clientSocket) const upstream = http.request( { host: '127.0.0.1', port: Number(backend.target.port) || 80, method: req.method, path: url.pathname + url.search, headers }, answer => { const answerHeaders = { ...answer.headers } for (const key of DROPPED_RESPONSE_HEADERS) { delete answerHeaders[key] } log(req.method, path, answer.statusCode ?? 502) res.writeHead(answer.statusCode ?? 502, answerHeaders) answer.pipe(res) } ) const release = () => { // Keep WebSocket/other requests on the same device tracked independently. device.sockets.delete(clientSocket) } upstream.on('error', () => { release() if (res.headersSent) { res.destroy() } else { reply(req, res, path, 502, { error: 'backend_unreachable' }) } }) res.on('close', () => { release() upstream.destroy() }) req.pipe(upstream) } async function onRequest(req: http.IncomingMessage, res: http.ServerResponse) { const raw = req.url ?? '' if (!raw.startsWith('/') || raw.startsWith('//')) { return reply(req, res, '(invalid)', 400, { error: 'invalid_request' }) } const url = new URL(raw, 'http://proxy.invalid') const path = decodeRequestPath(url.pathname) if (path === null) { return reply(req, res, '(invalid)', 400, { error: 'invalid_request' }) } if (path === '/aiturk/pair/hello' && req.method === 'GET') { return reply(req, res, path, 200, { app: 'aiturk-ide', version: options.version, pairing: pairingOpen() }) } if (path === '/aiturk/pair/claim' && req.method === 'POST') { return claim(req, res, path) } // The backend's `/` embeds its session token: nothing outside /api/ is ever forwarded. if (!path.startsWith('/api/')) { return reply(req, res, path, 404, { error: 'not_found' }) } const device = authenticate(req, url) if (!device) { return reply(req, res, path, 401, { error: 'unauthorized' }) } if (isPhoneForbidden(req.method ?? '', path)) { return reply(req, res, path, 403, { error: 'forbidden' }) } const backend = await localBackend() if (!backend) { return reply(req, res, path, 503, { error: 'backend_unavailable' }) } // Revoked while the backend was being resolved. if (!devices.has(device.tokenHash)) { return reply(req, res, path, 401, { error: 'unauthorized' }) } forward(req, res, url, path, device, backend) } async function onUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer) { const refuse = (path: string, status: number, error: string) => { const body = JSON.stringify({ error }) log(req.method, path, status) socket.end( `HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? 'Error'}\r\nConnection: close\r\nContent-Type: application/json\r\n` + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` ) } socket.on('error', () => socket.destroy()) const raw = req.url ?? '' const url = raw.startsWith('/') && !raw.startsWith('//') ? new URL(raw, 'http://proxy.invalid') : null const path = url ? decodeRequestPath(url.pathname) : null if (!url || path === null) { return refuse('(invalid)', 400, 'invalid_request') } if (!path.startsWith('/api/')) { return refuse(path, 404, 'not_found') } const device = authenticate(req, url) if (!device) { return refuse(path, 401, 'unauthorized') } if (!MOBILE_LAN_WS_PATHS.includes(path)) { return refuse(path, 403, 'forbidden') } const backend = await localBackend() if (!backend) { return refuse(path, 503, 'backend_unavailable') } if (!devices.has(device.tokenHash) || socket.destroyed) { return refuse(path, 401, 'unauthorized') } url.searchParams.set('token', backend.token) const upstream = net.connect(Number(backend.target.port) || 80, '127.0.0.1') let closed = false const close = () => { if (!closed) { closed = true device.sockets.delete(socket) sockets.delete(upstream) upstream.destroy() socket.destroy() } } device.sockets.add(socket) sockets.add(upstream) upstream.on('error', close) upstream.on('close', close) socket.on('close', close) upstream.once('connect', () => { // No Origin / Cookie / Authorization: only what the WebSocket handshake needs. const lines = [`GET ${path}${url.search} HTTP/1.1`, `Host: ${backend.target.host}`, 'Connection: Upgrade', 'Upgrade: websocket'] for (const key of ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol']) { const value = req.headers[key] if (typeof value === 'string' && value) { lines.push(`${key}: ${value}`) } } log(req.method, path, 101) upstream.write(`${lines.join('\r\n')}\r\n\r\n`) if (head?.length) { upstream.write(head) } upstream.pipe(socket) socket.pipe(upstream) }) } const server = https.createServer({ key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' }, (req, res) => { onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' })) }) server.on('upgrade', (req, socket, head) => { onUpgrade(req, socket, head).catch(() => socket.destroy()) }) server.on('connection', socket => { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) }) server.on('clientError', (_error, socket) => socket.destroy()) server.on('tlsClientError', () => undefined) return { get port() { return listeningPort }, get listening() { return listeningPort !== null }, listen(port: number, host: string): Promise { return new Promise((resolve, reject) => { const onError = (error: Error) => reject(error) server.once('error', onError) server.listen(port, host, () => { server.removeListener('error', onError) server.on('error', () => undefined) listeningPort = (server.address() as net.AddressInfo).port resolve(listeningPort) }) }) }, stop(): Promise { return new Promise(resolve => { if (listeningPort === null) { resolve() return } listeningPort = null for (const socket of sockets) { socket.destroy() } server.close(() => resolve()) }) }, /** Starts a pairing session; any previous one is dropped. The code is returned once. */ beginPairing(): { code: string; expiresAt: number } { const code = generatePairCode(randomBytes) pairing = { codeHash: crypto.createHash('sha256').update(code).digest(), expiresAt: now() + codeTtlMs, attempts: 0 } return { code, expiresAt: pairing.expiresAt } }, cancelPairing(): boolean { if (!pairing) { return false } endPairing('cancelled') return true }, pairingOpen, records, devices: (): MobileDeviceView[] => [...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ id, name, createdAt, lastSeenAt, live: live.size })), /** Removes the device: its token turns 401 and its open connections are closed. */ async revoke(id: string): Promise { for (const [hash, device] of devices) { if (device.id === id) { devices.delete(hash) for (const socket of device.sockets) { socket.destroy() } device.sockets.clear() await options.onDevicesChanged?.(records()) return true } } return false } } } export type LanProxy = ReturnType // ── Persistent state (no secrets: token digests only) ─────────────────────── export interface MobileLinkStored { enabled: boolean port: null | number /** Port the Windows Firewall rule was last added for. */ firewallPort: null | number /** Fingerprint the devices were paired against. */ fingerprint: null | string devices: MobileDeviceRecord[] } export interface MobileLinkStore { load: () => Promise save: (value: MobileLinkStored) => Promise } export interface MobileLinkStoreFs { readFile: (file: string, encoding: 'utf8') => Promise writeFile: (file: string, data: string, options: { mode: number }) => Promise rename: (from: string, to: string) => Promise mkdir: (directory: string, options: { recursive: true }) => Promise } export function createMobileLinkFileStore(directory: string, file: string, fs: MobileLinkStoreFs): MobileLinkStore { return { async load() { try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { return null } }, async save(value) { await fs.mkdir(directory, { recursive: true }) await fs.writeFile(`${file}.tmp`, JSON.stringify({ v: 1, ...value }), { mode: 0o600 }) await fs.rename(`${file}.tmp`, file) } } } const validPort = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536 export function parseMobileLinkStored(raw: unknown): MobileLinkStored { const row = (raw && typeof raw === 'object' ? raw : {}) as Record const devices: MobileDeviceRecord[] = [] for (const item of Array.isArray(row.devices) ? row.devices : []) { const device = item as MobileDeviceRecord if ( device && typeof device.id === 'string' && UUID_RE.test(device.id) && typeof device.name === 'string' && typeof device.tokenHash === 'string' && HASH_RE.test(device.tokenHash) && Number.isFinite(device.createdAt) ) { devices.push({ id: device.id, name: device.name.slice(0, 60), tokenHash: device.tokenHash, createdAt: device.createdAt, lastSeenAt: Number.isFinite(device.lastSeenAt) ? device.lastSeenAt : device.createdAt }) } } return { enabled: row.enabled === true, port: validPort(row.port) ? row.port : null, firewallPort: validPort(row.firewallPort) ? row.firewallPort : null, fingerprint: typeof row.fingerprint === 'string' ? row.fingerprint : null, devices } } // ── The controller the window and main.ts talk to ─────────────────────────── export type MobileLinkErrorCode = | 'remote_mode' | 'vault_unavailable' | 'not_enabled' | 'no_lan_address' | 'port_unavailable' | 'invalid_port' | 'invalid_request' | 'unsupported' /** Message is ONLY a fixed code, so nothing secret can ride an error to the UI or the log. */ export class MobileLinkError extends Error { code: MobileLinkErrorCode constructor(code: MobileLinkErrorCode) { super(`aiturk-mobile:${code}`) this.name = 'MobileLinkError' this.code = code } } export type MobileFirewallState = 'ok' | 'needed' | 'denied' | 'failed' | 'not-needed' export interface MobileLinkState { enabled: boolean listening: boolean port: null | number /** IDE is in remote-gateway mode: the feature is refused. */ remote: boolean firewall: MobileFirewallState pcName: string pairing: null | { /** Raw 8 characters; the window shows it as ABCD-EFGH. */ code: string qr: string expiresAt: number /** Gateway rendezvous for the "type the code" path. */ codePath: 'pending' | 'ready' | 'unavailable' } devices: MobileDeviceView[] } export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState } export interface MobileLinkDeps { vault: MobileLanVault store: MobileLinkStore resolveBackend: () => Promise isRemoteMode: () => boolean firewall: MobileFirewall fetch: PairFetch /** Private IPv4 candidates, default route first. */ addresses: () => Promise version: string pcName: string platform: string listenHost?: string /** Candidate ports, tried in order when none is stored yet (or the stored one is taken). */ ports?: number[] now?: () => number randomBytes?: (size: number) => Buffer randomUUID?: () => string log?: (line: string) => void setTimer?: (fn: () => void, ms: number) => unknown clearTimer?: (handle: unknown) => void } function defaultPorts(): number[] { const ports: number[] = [] for (let port = MOBILE_LAN_DEFAULT_PORT; port <= MOBILE_LAN_LAST_PORT; port++) { ports.push(port) } return ports } /** Find a free port by binding and releasing it; nothing is ever accepted on it. */ function probeFreePort(port: number, host: string): Promise { return new Promise(resolve => { const probe = net.createServer() probe.once('error', () => resolve(null)) probe.listen(port, host, () => { const actual = (probe.address() as net.AddressInfo).port probe.close(() => resolve(actual)) }) }) } export function createMobileLink(deps: MobileLinkDeps) { const now = deps.now ?? Date.now const listenHost = deps.listenHost ?? '0.0.0.0' const ports = deps.ports ?? defaultPorts() const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout)) const listeners = new Set<(event: MobileLinkEvent) => void>() const pcName = (deps.pcName || 'AITURK IDE').slice(0, 60) let stored: MobileLinkStored | null = null let identity: MobileLanIdentity | null = null let proxy: LanProxy | null = null let firewallFailure: null | 'denied' | 'failed' = null let session: null | { code: string; qr: string; expiresAt: number; codePath: 'pending' | 'ready' | 'unavailable'; offer: null | PairOffer; timer: unknown } = null let queue: Promise = Promise.resolve() let stopped = false /** Actions run one at a time so two IPC calls cannot interleave listen/stop. */ function serial(work: () => Promise): Promise { const next = queue.then(work, work) queue = next.catch(() => undefined) return next } async function load(): Promise { if (!stored) { stored = parseMobileLinkStored(await deps.store.load()) } return stored } const persist = async () => { if (stored) { await deps.store.save({ ...stored, devices: proxy ? proxy.records() : stored.devices }) } } function firewallState(data: MobileLinkStored): MobileFirewallState { if (deps.platform !== 'win32' || data.port === null) { return 'not-needed' } if (data.firewallPort === data.port) { return 'ok' } return firewallFailure ?? 'needed' } function snapshot(data: MobileLinkStored): MobileLinkState { return { enabled: data.enabled, listening: Boolean(proxy?.listening), port: data.port, remote: deps.isRemoteMode(), firewall: firewallState(data), pcName, pairing: session ? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath } : null, devices: proxy ? proxy.devices() : data.devices.map(({ id, name, createdAt, lastSeenAt }) => ({ id, name, createdAt, lastSeenAt, live: 0 })) } } function emit(extra?: { paired: string }) { if (!stored) { return } const state = snapshot(stored) const event: MobileLinkEvent = extra ? { type: 'paired', name: extra.paired, state } : { type: 'state', state } for (const listener of listeners) { try { listener(event) } catch { // a broken listener must not break the proxy } } } function dropSession(cancelOffer: boolean) { const ended = session session = null if (ended) { clearTimer(ended.timer) if (cancelOffer && ended.offer) { void cancelPairOffer(deps.fetch, ended.offer) } } } async function ensureProxy(data: MobileLinkStored): Promise { if (proxy && identity) { return proxy } try { identity = await loadOrCreateLanIdentity(deps.vault, { now: () => new Date(now()) }) } catch { throw new MobileLinkError('vault_unavailable') } // A new certificate means every phone's pin is stale: those tokens can no // longer be presented over a connection the phone would trust. if (data.fingerprint !== identity.fingerprint) { if (data.fingerprint !== null) { data.devices = [] } data.fingerprint = identity.fingerprint await deps.store.save(data) } proxy = createLanProxy({ tls: identity, resolveBackend: async () => { if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } return deps.resolveBackend() }, version: deps.version, pcName, devices: data.devices, now, randomBytes: deps.randomBytes, randomUUID: deps.randomUUID, log: deps.log, onDevicesChanged: async devices => { data.devices = devices await deps.store.save(data) }, onPairingEnded: (reason, device) => { // The gateway record is useless once the code is spent, locked or expired. dropSession(true) void serial(() => syncListening(data)).then( () => emit(reason === 'claimed' && device ? { paired: device.name } : undefined), () => emit() ) } }) return proxy } async function ensurePort(data: MobileLinkStored): Promise { if (data.port !== null) { return data.port } for (const candidate of ports) { const free = await probeFreePort(candidate, listenHost) if (free !== null) { data.port = free return free } } throw new MobileLinkError('port_unavailable') } async function startListening(data: MobileLinkStored, instance: LanProxy) { if (instance.listening) { return } const candidates = data.port !== null ? [data.port, ...ports.filter(port => port !== data.port)] : ports for (const candidate of candidates) { try { const actual = await instance.listen(candidate, listenHost) if (data.port !== actual) { // The rule (if any) was for another port; the UI offers to add it again. data.port = actual await persist() } return } catch { // taken; try the next one } } throw new MobileLinkError('port_unavailable') } /** Listen only while enabled AND (a phone is paired OR pairing is in progress). */ async function syncListening(data: MobileLinkStored) { const wanted = () => !stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null) // ensureProxy may drop phones paired against a replaced certificate, so ask again after it. const instance = wanted() ? await ensureProxy(data) : proxy if (!wanted()) { await instance?.stop() return } await startListening(data, instance as LanProxy) } async function addFirewallRule(data: MobileLinkStored) { if (deps.platform !== 'win32' || data.port === null || data.firewallPort === data.port) { return } const result = await deps.firewall.addRule(data.port) if (result.ok) { firewallFailure = null data.firewallPort = data.port } else { firewallFailure = result.reason ?? 'failed' } await persist() } const api = { onEvent(listener: (event: MobileLinkEvent) => void): () => void { listeners.add(listener) return () => listeners.delete(listener) }, state: () => serial(async () => snapshot(await load())), /** App start: resume listening when the feature is on and a phone is paired. No UAC, no pairing. */ start: () => serial(async () => { const data = await load() stopped = false if (data.enabled && data.devices.length > 0 && !deps.isRemoteMode()) { await syncListening(data) } return snapshot(data) }), /** App quit / feature teardown: close every socket. Stored state is kept. */ stop: () => serial(async () => { stopped = true proxy?.cancelPairing() dropSession(true) await proxy?.stop() }), enable: () => serial(async () => { const data = await load() if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } stopped = false await ensureProxy(data) await ensurePort(data) data.enabled = true await persist() // First time on this port: one UAC prompt. A refusal does not undo "enabled"; // the window shows a warning and "Yeniden dene". await addFirewallRule(data) await syncListening(data) return snapshot(data) }), /** Stops listening. The firewall rule and the paired phones are kept. */ disable: () => serial(async () => { const data = await load() proxy?.cancelPairing() dropSession(true) data.enabled = false await persist() await syncListening(data) return snapshot(data) }), pairBegin: () => serial(async () => { const data = await load() if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } if (!data.enabled) { throw new MobileLinkError('not_enabled') } const instance = await ensureProxy(data) const addresses = (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) if (!addresses.length) { throw new MobileLinkError('no_lan_address') } dropSession(true) await startListening(data, instance) const port = data.port as number const fingerprint = (identity as MobileLanIdentity).fingerprint const { code, expiresAt } = instance.beginPairing() const current = { code, qr: buildPairQrPayload({ addresses, port, fingerprint, code }), expiresAt, codePath: 'pending' as 'pending' | 'ready' | 'unavailable', offer: null as null | PairOffer, // The proxy notices expiry lazily (on a request); this closes the socket on time. timer: setTimer(() => { instance.pairingOpen() }, Math.max(0, expiresAt - now()) + 50) } session = current // Not awaited: the QR must not wait for the gateway. void publishPairOffer(deps.fetch, { code, addresses, port, fingerprint }).then(offer => { if (session !== current) { // Pairing ended meanwhile; do not leave the record behind. if (offer) { void cancelPairOffer(deps.fetch, offer) } return } current.offer = offer current.codePath = offer ? 'ready' : 'unavailable' emit() }) return snapshot(data) }), pairCancel: () => serial(async () => { const data = await load() proxy?.cancelPairing() dropSession(true) await syncListening(data) return snapshot(data) }), devices: () => serial(async () => snapshot(await load()).devices), deviceRevoke: (id: unknown) => serial(async () => { const data = await load() if (typeof id !== 'string' || !UUID_RE.test(id)) { throw new MobileLinkError('invalid_request') } if (proxy) { await proxy.revoke(id) } else { data.devices = data.devices.filter(device => device.id !== id) await persist() } await syncListening(data) return snapshot(data) }), firewallRetry: () => serial(async () => { const data = await load() if (!data.enabled) { throw new MobileLinkError('not_enabled') } await addFirewallRule(data) return snapshot(data) }), firewallRemove: () => serial(async () => { const data = await load() if (deps.platform === 'win32') { const result = await deps.firewall.removeRule() if (result.ok) { data.firewallPort = null firewallFailure = null await persist() } else { firewallFailure = result.reason ?? 'failed' } } return snapshot(data) }) } return api } export type MobileLink = ReturnType