/** * 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) for the proxy and * pairing rules, workspace/aiturk-mobil-tunel-spec.md (v3) for how a phone gets * here. The Android app and the gateway are written against the same * documents, so paths, field names and error codes here must not drift. * * Since beta.16 the proxy binds to 127.0.0.1 ONLY by default: phones arrive * through the turkservis.online tunnel (electron/mobile-relay-client.ts), which * hands this proxy raw TCP on loopback, so TLS still ends here and the phone * still pins this certificate. Binding to the LAN and the Windows Firewall rule * happen only after the user allows "Aynı Wi-Fi'da doğrudan bağlantı" * (`lanDirect`); turning the feature on never raises a UAC prompt. * * 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' import { createRelayClient, isRelayCredentials, isRelayId, RELAY_MAX_FRAME_BYTES, type RelayClient, type RelayConnect, type RelayFailureCode, type RelayStatus, type RelayWebSocketCtor } from './mobile-relay-client' 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' /** Default bind: the relay client is the only caller, so nothing is reachable from the network. */ export const MOBILE_LOOPBACK_HOST = '127.0.0.1' /** Bind used only after the user allowed "Aynı Wi-Fi'da doğrudan bağlantı". */ export const MOBILE_LAN_DIRECT_HOST = '0.0.0.0' 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=…]&fp=<43>&c=[&r=]` * (v1 §2 + tunnel spec v3 §2). `u` is present only when the phone can really * reach this computer on the LAN; `r` (a plain uuid) only when a relay * registration exists. A QR with neither would be a code that cannot work, so * that is refused. */ export function buildPairQrPayload(input: { addresses: string[] port: number fingerprint: string code: string relay?: null | string }): string { const hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) const relay = isRelayId(input.relay) ? input.relay : null if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { throw new MobileLinkError('no_lan_address') } if (!hosts.length && !relay) { throw new MobileLinkError('no_lan_address') } // ip:port, the fingerprint (base64url), the code and a uuid need no escaping in a query. const parts = ['v=1', ...hosts.map(host => `u=${host}:${input.port}`), `fp=${input.fingerprint}`, `c=${input.code}`] if (relay) { parts.push(`r=${relay}`) } return `aiturk://pair?${parts.join('&')}` } // ── 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 /** * A published rendezvous record. Both fields are null when the record is known * to exist but its answer never reached us (an earlier try failed on the wire * and the next one got 409): the code path works, there is just nothing to * cancel — the record expires on its own after five minutes. */ export interface PairOffer { offerId: null | string cancelToken: null | string } export const PAIR_OFFER_MAX_TRIES = 4 /** Waits between the tries: ~0.5 s, 1.5 s, 3 s. */ export const PAIR_OFFER_RETRY_DELAYS_MS: readonly number[] = [500, 1500, 3000] export interface PairOfferOptions { /** Injected wait (the controller passes its own timer). Default: a real timer. */ sleep?: (ms: number) => Promise delaysMs?: readonly number[] maxTries?: number /** true = the pairing this record belongs to is over: stop trying. */ cancelled?: () => boolean /** "offer published" / "offer failed after N tries" only — never the code, the lookup or a token. */ log?: (line: string) => void /** * An earlier publish for the SAME code may have reached the gateway (its * answer was lost), so a 409 on the very first try also means "it is there". */ earlierTryMayHaveLanded?: boolean /** Called when a try ended without a usable answer (thrown error / 5xx), i.e. it may have been stored. */ onMayHaveLanded?: () => void } 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 when it could not be published — * the QR path keeps working and the UI shows "kodla bağlanma kullanılamıyor". * * Up to four tries. A long-idle process can lose its first request on a dead * pooled keep-alive connection, and one lost request must not close the code * path for the whole five minutes: * - a thrown network error, 5xx and 429 are tried again; * - 422 and every other answer are final (the request itself is wrong); * - 409 after a try that failed on the wire / with 5xx means that try DID * land and only its answer was lost: the record exists, the code path is * ready (no cancel token is known for it). */ export async function publishPairOffer( fetcher: PairFetch, input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string }, options: PairOfferOptions = {} ): Promise { const relay = isRelayId(input.relay) ? input.relay : null const urls = input.addresses .filter(isPrivateIpv4) .slice(0, MOBILE_LAN_MAX_URLS) .map(host => `https://${host}:${input.port}`) // Tunnel spec §1: `urls` may be empty, but then `relay` is required. if (!urls.length && !relay) { return null } const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) const delays = options.delaysMs ?? PAIR_OFFER_RETRY_DELAYS_MS const maxTries = Math.max(1, options.maxTries ?? PAIR_OFFER_MAX_TRIES) const cancelled = options.cancelled ?? (() => false) const body = { lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) } let mayHaveLanded = options.earlierTryMayHaveLanded === true let tries = 0 const failed = () => { options.log?.(`offer failed after ${tries} ${tries === 1 ? 'try' : 'tries'}`) return null } const published = (offer: PairOffer) => { options.log?.('offer published') return offer } while (tries < maxTries) { if (cancelled()) { return null } tries++ let status: number let data: null | Record = null try { // A fresh init per try: the timeout signal must not be shared between tries. const response = await fetcher(`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, pairFetchInit(body)) status = response.status if (status === 201) { data = (await response.json()) as null | Record } } catch { // No (complete) answer: the request may or may not have been stored. status = 0 } if (status === 201) { if ( !data || typeof data.offerId !== 'string' || !UUID_RE.test(data.offerId) || typeof data.cancelToken !== 'string' || !CANCEL_TOKEN_RE.test(data.cancelToken) ) { return failed() } return published({ offerId: data.offerId, cancelToken: data.cancelToken }) } if (status === 409 && mayHaveLanded) { return published({ offerId: null, cancelToken: null }) } const transient = status === 0 || status === 429 || (status >= 500 && status <= 599) if (!transient) { return failed() } if (status !== 429) { mayHaveLanded = true options.onMayHaveLanded?.() } if (tries < maxTries) { await sleep(delays[Math.min(tries - 1, delays.length - 1)] ?? 0) } } return cancelled() ? null : failed() } /** Best effort; the record expires on its own after five minutes anyway. No token = nothing to cancel. */ export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise { if (!offer.cancelToken) { return } 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' } /** What Windows really has under our rule name right now. */ export type FirewallRuleInfo = { state: 'present'; ports: number[] } | { state: 'absent' } | { state: 'unknown' } export interface MobileFirewall { addRule: (port: number) => Promise removeRule: () => Promise /** Read-only, no elevation. Missing = the state cannot be read ('unknown'). */ queryRule?: () => Promise } /** `netsh advfirewall firewall show rule name=…` — fixed arguments, no shell, no elevation. */ export function firewallQueryCommand(systemRoot?: string) { const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows' return { file: `${root}\\System32\\netsh.exe`, args: ['advfirewall', 'firewall', 'show', 'rule', `name=${MOBILE_FIREWALL_RULE_NAME}`] } } /** * Reads the answer of the query above. netsh prints localized labels in the * console code page, so nothing here depends on a translated word except the * "disabled" value (No / Hayır): a rule block is recognised by its line of * dashes, the port by `LocalPort` (not translated) or, failing that, by the * first value that is purely a port list. Exit code 1 with no rule block is * netsh's "No rules match the specified criteria." / * "Belirtilen ölçütlerle eşleşen kural yok." */ export function parseFirewallRuleOutput(stdout: string, exitCode: null | number): FirewallRuleInfo { const lines = String(stdout ?? '').split(/\r?\n/) const ports = new Set() let blocks = 0 for (let index = 0; index < lines.length; index++) { if (!/^-{20,}\s*$/.test(lines[index])) { continue } blocks++ const block: string[] = [] for (let cursor = index + 1; cursor < lines.length && lines[cursor].trim() !== ''; cursor++) { block.push(lines[cursor]) } const value = (line: string | undefined) => (line === undefined ? '' : line.slice(line.indexOf(':') + 1).trim()) // First line of a block is Enabled / Etkin. if (/^(?:no|hay)/i.test(value(block[0]))) { continue } const portLine = block.find(line => /^LocalPort\s*:/i.test(line)) ?? block.find(line => /^\d{1,5}(?:\s*[,-]\s*\d{1,5})*$/.test(value(line))) for (const part of value(portLine).split(',')) { const [from, to = from] = part.split('-').map(item => Number(item.trim())) if (Number.isInteger(from) && Number.isInteger(to) && from > 0 && to < 65536 && to - from < 64) { for (let port = from; port <= to; port++) { ports.add(port) } } } } if (blocks > 0) { return { state: 'present', ports: [...ports] } } // No block: only an explicit "nothing matched" (exit 1 with some text) counts as absent. return exitCode === 1 && lines.some(line => line.trim() !== '') ? { state: 'absent' } : { state: 'unknown' } } export type FirewallRun = (file: string, args: string[]) => Promise<{ code: null | number; stdout: string }> 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 /** Runs the read-only query and returns exit code + stdout; without it the state is 'unknown'. */ run?: FirewallRun }): 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), async queryRule() { if (deps.platform !== 'win32' || !deps.run) { return { state: 'unknown' } } try { const command = firewallQueryCommand(deps.env.SystemRoot) const result = await deps.run(command.file, command.args) return parseFirewallRuleOutput(result.stdout, result.code) } catch { return { state: 'unknown' } } } } } // ── 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: (profile: string) => Promise /** Resolution only, never the duration of an agent/tool/cron execution. */ backendResolveTimeoutMs?: number 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 let listeningHost: null | string = 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(url: URL): Promise { let timer: ReturnType | undefined try { // A paired phone controls this machine, but its selected profile is NOT // the desktop window's selection. `all` is an aggregate read on the root // dashboard, not a profile to spawn. Keep the original query downstream. const selected = url.searchParams.get('profile')?.trim().toLowerCase() || 'default' const profile = selected === 'all' ? 'default' : selected if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) return null const backend = await Promise.race([ options.resolveBackend(profile), new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('backend_not_ready')), options.backendResolveTimeoutMs ?? 8000) }) ]) 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 } finally { if (timer) clearTimeout(timer) } } 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(url) if (res.destroyed) return 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(url) if (socket.destroyed) return 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 }, /** Address the socket is bound to right now, or null. */ get host() { return listeningHost }, 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 listeningHost = host resolve(listeningPort) }) }) }, stop(): Promise { return new Promise(resolve => { if (listeningPort === null) { resolve() return } listeningPort = null listeningHost = 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[] /** * "Aynı Wi-Fi'da doğrudan bağlantıya da izin ver". Off by default (also for * records written by beta.15, which has no such field): the proxy then binds * to 127.0.0.1 only and phones come in through the relay. */ lanDirect: boolean /** Relay registration id (not a secret; the host token is in the vault). */ relayId: null | string /** The relay registration was replaced while phones were paired: they must pair again. */ relayReissued: boolean } 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, lanDirect: row.lanDirect === true, relayId: typeof row.relayId === 'string' && UUID_RE.test(row.relayId) ? row.relayId.toLowerCase() : null, relayReissued: row.relayReissued === true } } // ── The controller the window and main.ts talk to ─────────────────────────── export type MobileLinkErrorCode = | 'remote_mode' | 'vault_unavailable' | 'not_enabled' | 'no_lan_address' | 'relay_unavailable' | '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' /** The rule as Windows reports it right now (never the stored guess). */ export type MobileLanFirewall = 'allowed' | 'missing' | 'unknown' export interface MobileLinkState { enabled: boolean /** The local proxy has a socket (loopback or LAN). Says nothing about reachability from a phone. */ listening: boolean port: null | number /** IDE is in remote-gateway mode: the feature is refused. */ remote: boolean /** Outcome of the last "İzin ver" attempt on top of the real rule state. */ firewall: MobileFirewallState /** Connection through turkservis.online. Never carries the host token. */ relay: { status: RelayStatus /** Fixed code while `failed`. */ code: null | RelayFailureCode /** A registration exists, so a QR / code can carry the relay id. */ ready: boolean /** The registration was replaced: phones paired before must be paired again. */ reissued: boolean } /** Direct connection on the same Wi-Fi. */ lan: { /** The user allowed it ("Aynı Wi-Fi'da doğrudan bağlantıya da izin ver"). */ direct: boolean /** A socket is REALLY bound to the LAN right now (not just loopback). */ listening: boolean port: null | number firewall: MobileLanFirewall } 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' /** * "Güvenlik kodu": first 8 characters of the certificate fingerprint. The * phone shows the same 8 characters; comparing them closes the code path's * trust in the gateway (tunnel spec §4). */ securityCode: string } devices: MobileDeviceView[] } export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState } export interface MobileLinkDeps { vault: MobileLanVault store: MobileLinkStore resolveBackend: (profile: string) => Promise isRemoteMode: () => boolean firewall: MobileFirewall fetch: PairFetch /** Private IPv4 candidates, default route first. */ addresses: () => Promise version: string pcName: string platform: string /** Bind used while direct LAN access is allowed (default 0.0.0.0). */ listenHost?: string /** Bind used otherwise (default 127.0.0.1). */ loopbackHost?: string /** Candidate ports, tried in order when none is stored yet (or the stored one is taken). */ ports?: number[] /** * WebSocket client class that can send request headers (the `ws` package), * for the outbound host socket. Without it (and without `relayConnect`) the * relay stays off. */ WebSocket?: RelayWebSocketCtor /** Opens the host socket; tests point it at a fake relay on 127.0.0.1. */ relayConnect?: RelayConnect 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)) }) }) } interface PairSession { code: string qr: string expiresAt: number codePath: 'pending' | 'ready' | 'unavailable' offer: null | PairOffer timer: unknown addresses: string[] port: number fingerprint: string /** A publish got no answer at least once: a later 409 for this code is our own record. */ offerMayHaveLanded: boolean } export function createMobileLink(deps: MobileLinkDeps) { const now = deps.now ?? Date.now const lanHost = deps.listenHost ?? MOBILE_LAN_DIRECT_HOST const loopbackHost = deps.loopbackHost ?? MOBILE_LOOPBACK_HOST 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) const windows = deps.platform === 'win32' let stored: MobileLinkStored | null = null let identity: MobileLanIdentity | null = null let proxy: LanProxy | null = null let relay: RelayClient | null = null let firewallFailure: null | 'denied' | 'failed' = null /** Last answer of the real rule query. */ let rule: FirewallRuleInfo = { state: 'unknown' } let session: null | PairSession = 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 }) } } /** Ask Windows (read-only, no elevation). The stored `firewallPort` is never trusted. */ async function refreshFirewall() { if (!windows) { return } try { rule = (await deps.firewall.queryRule?.()) ?? { state: 'unknown' } } catch { rule = { state: 'unknown' } } } function lanFirewall(data: MobileLinkStored): MobileLanFirewall { if (!windows) { return 'allowed' } if (rule.state === 'unknown') { return 'unknown' } return rule.state === 'present' && data.port !== null && rule.ports.includes(data.port) ? 'allowed' : 'missing' } function firewallState(data: MobileLinkStored): MobileFirewallState { if (!windows) { return 'not-needed' } return lanFirewall(data) === 'allowed' ? 'ok' : (firewallFailure ?? 'needed') } /** * Bind to the LAN only when the user allowed it AND Windows is not known to * block the port (listening on all interfaces without a rule would make * Windows raise its own permission dialog). */ const directWanted = (data: MobileLinkStored) => data.lanDirect && lanFirewall(data) !== 'missing' const bindHost = (data: MobileLinkStored) => (directWanted(data) ? lanHost : loopbackHost) function snapshot(data: MobileLinkStored): MobileLinkState { const listening = Boolean(proxy?.listening) const relayState = relay?.state() ?? { status: 'off' as RelayStatus, code: null } return { enabled: data.enabled, listening, port: data.port, remote: deps.isRemoteMode(), firewall: firewallState(data), relay: { status: relayState.status, code: relayState.code, ready: data.relayId !== null, reissued: data.relayReissued }, lan: { direct: data.lanDirect, listening: listening && data.lanDirect && proxy?.host === lanHost, port: data.port, firewall: lanFirewall(data) }, pcName, pairing: session ? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath, securityCode: session.fingerprint.slice(0, 8) } : 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) } } } const sleep = (ms: number) => new Promise(resolve => void setTimer(resolve, ms)) /** * Publishes (or publishes again) the rendezvous record of `current` in the * background; the code path stays `pending` until every try is over. The * code and the QR are never touched here. */ function publishOffer(current: PairSession, relayId: null | string) { current.codePath = 'pending' const input = { code: current.code, addresses: current.addresses, port: current.port, fingerprint: current.fingerprint, relay: relayId } void publishPairOffer(deps.fetch, input, { sleep, cancelled: () => session !== current, log: line => deps.log?.(`pair ${line}`), earlierTryMayHaveLanded: current.offerMayHaveLanded, onMayHaveLanded: () => { current.offerMayHaveLanded = true } }).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() }) } /** The relay registration changed while a QR is on screen: show the current address. */ function refreshSessionQr(data: MobileLinkStored) { if (!session) { return } try { session.qr = buildPairQrPayload({ addresses: session.addresses, port: session.port, fingerprint: session.fingerprint, code: session.code, relay: data.relayId }) } catch { // neither a LAN address nor a relay left; the old payload stays until pairing ends } } 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 profile => { if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } return deps.resolveBackend(profile) }, 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) if (reason === 'claimed') { // This phone was paired against the current relay address. data.relayReissued = false } void serial(() => syncListening(data)).then( () => emit(reason === 'claimed' && device ? { paired: device.name } : undefined), () => emit() ) } }) return proxy } /** * The tunnel client. It only copies bytes between the gateway and a plain TCP * connection to the proxy; TLS stays between the phone and the proxy. null * when no header-capable WebSocket client was injected. */ function ensureRelay(data: MobileLinkStored): null | RelayClient { if (relay || (!deps.WebSocket && !deps.relayConnect)) { return relay } const WebSocketCtor = deps.WebSocket relay = createRelayClient({ connect: deps.relayConnect ?? ((url, headers) => new (WebSocketCtor as RelayWebSocketCtor)(url, { headers, handshakeTimeout: 20_000, perMessageDeflate: false, followRedirects: false, maxPayload: RELAY_MAX_FRAME_BYTES })), fetch: deps.fetch, dial: () => { const port = proxy?.port ?? null const host = proxy?.host ?? null if (port === null || host === null) { return null } // A socket bound to every interface is reached on loopback; anything else on its own address. return net.connect({ port, host: host === MOBILE_LAN_DIRECT_HOST ? MOBILE_LOOPBACK_HOST : host, noDelay: true }) }, credentials: { // relayId on disk, hostToken only in the vault; both must describe the same registration. async load() { if (!data.relayId) { return null } const row = (await deps.vault.load())?.relay return isRelayCredentials(row) && row.relayId === data.relayId ? { relayId: row.relayId, hostToken: row.hostToken } : null }, async save(credentials, info) { const previous = data.relayId await deps.vault.save({ ...((await deps.vault.load()) ?? {}), relay: { relayId: credentials.relayId, hostToken: credentials.hostToken } }) data.relayId = credentials.relayId // The public address the paired phones stored is gone with the old registration. if ((info.reissued || (previous !== null && previous !== credentials.relayId)) && data.devices.length > 0) { data.relayReissued = true } await persist() refreshSessionQr(data) emit() }, async clear() { if (data.relayId !== null && data.devices.length > 0) { data.relayReissued = true } data.relayId = null await persist() } }, setTimer, clearTimer, log: deps.log, onState: () => emit() }) return relay } async function ensurePort(data: MobileLinkStored): Promise { if (data.port !== null) { return data.port } for (const candidate of ports) { const free = await probeFreePort(candidate, bindHost(data)) if (free !== null) { data.port = free return free } } throw new MobileLinkError('port_unavailable') } async function startListening(data: MobileLinkStored, instance: LanProxy) { const host = bindHost(data) if (instance.listening) { if (instance.host === host) { return } // "Doğrudan bağlantı" was switched: move the socket between loopback and the LAN. await instance.stop() } 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, host) 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 — and keep the relay socket open — only while enabled AND (a phone * is paired OR pairing is in progress). Both follow the same condition. */ 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()) { relay?.stop() await instance?.stop() return } await startListening(data, instance as LanProxy) ensureRelay(data)?.start() } /** Cancels a pairing whose QR would no longer describe how the phone can connect. */ function endPairingQuietly() { proxy?.cancelPairing() dropSession(true) } const api = { onEvent(listener: (event: MobileLinkEvent) => void): () => void { listeners.add(listener) return () => listeners.delete(listener) }, state: () => serial(async () => { const data = await load() await refreshFirewall() // The rule was removed (or added back) outside the IDE: follow it. if (proxy?.listening && proxy.host !== bindHost(data)) { await syncListening(data) } return snapshot(data) }), /** App start: resume 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 refreshFirewall() 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) relay?.stop() await proxy?.stop() }), /** Turns the feature on. Never touches the firewall and never raises a UAC prompt. */ enable: () => serial(async () => { const data = await load() if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } stopped = false await ensureProxy(data) await refreshFirewall() await ensurePort(data) data.enabled = true await persist() await syncListening(data) return snapshot(data) }), /** Stops listening and the relay. The firewall rule and the paired phones are kept. */ disable: () => serial(async () => { const data = await load() endPairingQuietly() 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) await refreshFirewall() // Stored registration, or one `POST /sessions` (bounded by the fetch timeout). const relayUrl = (await ensureRelay(data)?.ensureRegistered()) ?? null // Without the relay AND without direct LAN access there is no way in: show no dead code. if (!relayUrl && !data.lanDirect) { throw new MobileLinkError('relay_unavailable') } // LAN candidates only when the phone could really reach them. const addresses = directWanted(data) ? (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) : [] if (!relayUrl && !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: PairSession = { code, qr: buildPairQrPayload({ addresses, port, fingerprint, code, relay: relayUrl }), expiresAt, codePath: 'pending', offer: null, // The proxy notices expiry lazily (on a request); this closes the socket on time. timer: setTimer( () => { instance.pairingOpen() }, Math.max(0, expiresAt - now()) + 50 ), addresses, port, fingerprint, offerMayHaveLanded: false } session = current ensureRelay(data)?.start() // Not awaited: the QR must not wait for the gateway. publishOffer(current, relayUrl) return snapshot(data) }), /** * "Yeniden dene" next to "Kodla bağlanma şu an kullanılamıyor": publishes * the rendezvous record of the RUNNING pairing again. Same code, same QR, * same expiry; nothing happens unless the code path is `unavailable`. */ pairRepublish: () => serial(async () => { const data = await load() if (session?.codePath === 'unavailable' && proxy?.pairingOpen() && session) { publishOffer(session, data.relayId) } return snapshot(data) }), pairCancel: () => serial(async () => { const data = await load() endPairingQuietly() 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() } if (data.devices.length === 0 && data.relayReissued) { data.relayReissued = false await persist() } await syncListening(data) return snapshot(data) }), /** * "Aynı Wi-Fi'da doğrudan bağlantıya da izin ver": the ONLY place that adds * the Windows Firewall rule (one UAC prompt, asked because the user pressed * the button). Direct access is switched on only when the rule is in place. */ lanAllow: () => serial(async () => { const data = await load() if (!data.enabled) { throw new MobileLinkError('not_enabled') } if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } await ensureProxy(data) await ensurePort(data) await refreshFirewall() if (windows && lanFirewall(data) !== 'allowed') { const result = await deps.firewall.addRule(data.port as number) if (!result.ok) { firewallFailure = result.reason ?? 'failed' await persist() return snapshot(data) } data.firewallPort = data.port await refreshFirewall() } firewallFailure = null data.lanDirect = true // A QR on screen was built without LAN addresses. endPairingQuietly() await persist() await syncListening(data) return snapshot(data) }), /** * "Röle kimliğini yenile": new relay registration, every paired phone is * dropped (its stored address and, if the token leaked, its path are dead) * and the window tells the user to pair again. */ relayRenew: () => serial(async () => { const data = await load() if (!data.enabled) { throw new MobileLinkError('not_enabled') } if (deps.isRemoteMode()) { throw new MobileLinkError('remote_mode') } const instance = await ensureProxy(data) const client = ensureRelay(data) endPairingQuietly() if (!client || !(await client.renew())) { throw new MobileLinkError('relay_unavailable') } const hadPhones = data.devices.length > 0 for (const device of instance.devices()) { await instance.revoke(device.id) } data.relayReissued = hadPhones await persist() await syncListening(data) return snapshot(data) }), /** Back to loopback only. The firewall rule is left alone (no UAC). */ lanDisallow: () => serial(async () => { const data = await load() data.lanDirect = false firewallFailure = null endPairingQuietly() await persist() await syncListening(data) return snapshot(data) }), /** Removes the rule (one UAC prompt) and, with it, direct LAN access. */ firewallRemove: () => serial(async () => { const data = await load() if (windows) { const result = await deps.firewall.removeRule() if (result.ok) { data.firewallPort = null data.lanDirect = false firewallFailure = null endPairingQuietly() await persist() await refreshFirewall() await syncListening(data) } else { firewallFailure = result.reason ?? 'failed' } } return snapshot(data) }) } // Kept for callers written against beta.15. return { ...api, firewallAllow: api.lanAllow, firewallRetry: api.lanAllow } } export type MobileLink = ReturnType