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

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

1604 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, LanInterfaceInfo[] | undefined>,
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<null | string> {
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=<ip:port>[&u=…]&fp=<43>&c=<code>` (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<unknown>
}
export type PairFetch = (url: string, init: Record<string, unknown>) => Promise<PairFetchResponse>
export interface PairOffer {
offerId: string
cancelToken: string
}
function pairFetchInit(body: unknown, headers: Record<string, string> = {}) {
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<null | PairOffer> {
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<string, unknown>
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<void> {
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<FirewallResult>
removeRule: () => Promise<FirewallResult>
}
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<string, unknown>): {
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<string, string | undefined>
}): MobileFirewall {
const run = (action: 'add' | 'remove', port: number) =>
new Promise<FirewallResult>(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<MobileBackend>
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> | 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<number, string> = { 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<string, LiveDevice>()
const sockets = new Set<Duplex>()
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<null | { target: URL; token: string }> {
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<null | string> {
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<string, string | string[]> = {}
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<number> {
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<void> {
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<boolean> {
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<typeof createLanProxy>
// ── 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<unknown>
save: (value: MobileLinkStored) => Promise<void>
}
export interface MobileLinkStoreFs {
readFile: (file: string, encoding: 'utf8') => Promise<string>
writeFile: (file: string, data: string, options: { mode: number }) => Promise<void>
rename: (from: string, to: string) => Promise<void>
mkdir: (directory: string, options: { recursive: true }) => Promise<unknown>
}
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<string, unknown>
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<MobileBackend>
isRemoteMode: () => boolean
firewall: MobileFirewall
fetch: PairFetch
/** Private IPv4 candidates, default route first. */
addresses: () => Promise<string[]>
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<null | number> {
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<unknown> = Promise.resolve()
let stopped = false
/** Actions run one at a time so two IPC calls cannot interleave listen/stop. */
function serial<T>(work: () => Promise<T>): Promise<T> {
const next = queue.then(work, work)
queue = next.catch(() => undefined)
return next
}
async function load(): Promise<MobileLinkStored> {
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<LanProxy> {
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<number> {
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<typeof createMobileLink>