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
1604 lines
48 KiB
TypeScript
1604 lines
48 KiB
TypeScript
/**
|
||
* 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>
|