The phone link no longer depends on the local network. The IDE dials out to turkservis.online and bridges tunnel streams to its own TLS proxy on loopback; the phone runs TLS to the pinned IDE certificate inside the tunnel, so the relay only carries ciphertext. The proxy binds to 127.0.0.1 by default and enabling the feature never asks for a firewall rule; direct same-network access is an explicit option. The window shows the real relay, listener and firewall state, and pairing no longer needs local addresses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2085 lines
65 KiB
TypeScript
2085 lines
65 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) for the proxy and
|
||
* pairing rules, workspace/aiturk-mobil-tunel-spec.md (v3) for how a phone gets
|
||
* here. The Android app and the gateway are written against the same
|
||
* documents, so paths, field names and error codes here must not drift.
|
||
*
|
||
* Since beta.16 the proxy binds to 127.0.0.1 ONLY by default: phones arrive
|
||
* through the turkservis.online tunnel (electron/mobile-relay-client.ts), which
|
||
* hands this proxy raw TCP on loopback, so TLS still ends here and the phone
|
||
* still pins this certificate. Binding to the LAN and the Windows Firewall rule
|
||
* happen only after the user allows "Aynı Wi-Fi'da doğrudan bağlantı"
|
||
* (`lanDirect`); turning the feature on never raises a UAC prompt.
|
||
*
|
||
* Security contract (do not relax):
|
||
* - `/` and everything outside `/api/` is answered here with 404 and never
|
||
* forwarded: the backend's root page embeds its session token.
|
||
* - A phone only ever knows its own device token (`amd_…`). The backend's
|
||
* real session token is added here and never sent to the phone. Only the
|
||
* SHA-256 of a device token is kept (memory and disk).
|
||
* - Authentication happens BEFORE the backend is resolved, so an
|
||
* unauthenticated LAN request can never start a backend.
|
||
* - Tokens, pairing codes and bodies are never logged or put into error
|
||
* messages; the log line is method + path (no query) + status.
|
||
* - The rendezvous record holds no secret: the raw code never leaves this
|
||
* machine, only hex(SHA-256("aiturk-mobile-pair-v1:" + CODE)).
|
||
*
|
||
* All I/O that matters for tests is injected (backend resolver, clock,
|
||
* randomness, store, vault, firewall, fetch, address discovery); node's
|
||
* https/net modules are used for real so the tests exercise real TLS sockets.
|
||
*/
|
||
|
||
import crypto from 'node:crypto'
|
||
import dgram from 'node:dgram'
|
||
import http from 'node:http'
|
||
import https from 'node:https'
|
||
import net from 'node:net'
|
||
import type { Duplex } from 'node:stream'
|
||
|
||
import { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert'
|
||
import {
|
||
createRelayClient,
|
||
isRelayCredentials,
|
||
isRelayId,
|
||
RELAY_MAX_FRAME_BYTES,
|
||
type RelayClient,
|
||
type RelayConnect,
|
||
type RelayFailureCode,
|
||
type RelayStatus,
|
||
type RelayWebSocketCtor
|
||
} from './mobile-relay-client'
|
||
|
||
export const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
export const PAIR_CODE_LENGTH = 8
|
||
export const PAIR_CODE_TTL_MS = 5 * 60_000
|
||
export const PAIR_MAX_WRONG_ATTEMPTS = 5
|
||
export const PAIR_CLAIM_MAX_BODY_BYTES = 4096
|
||
export const MOBILE_LAN_DEFAULT_PORT = 47321
|
||
export const MOBILE_LAN_LAST_PORT = 47340
|
||
export const MOBILE_LAN_MAX_URLS = 4
|
||
export const MOBILE_PAIR_ORIGIN = 'https://turkservis.online'
|
||
export const MOBILE_PAIR_FETCH_TIMEOUT_MS = 20_000
|
||
export const MOBILE_FIREWALL_RULE_NAME = 'AITURK IDE Telefon Baglantisi'
|
||
/** Default bind: the relay client is the only caller, so nothing is reachable from the network. */
|
||
export const MOBILE_LOOPBACK_HOST = '127.0.0.1'
|
||
/** Bind used only after the user allowed "Aynı Wi-Fi'da doğrudan bağlantı". */
|
||
export const MOBILE_LAN_DIRECT_HOST = '0.0.0.0'
|
||
export const MOBILE_LAN_WS_PATHS: readonly string[] = ['/api/ws', '/api/plugins/kanban/events']
|
||
|
||
const DEVICE_TOKEN_RE = /^amd_[A-Za-z0-9_-]{43}$/
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||
const CANCEL_TOKEN_RE = /^tsmp_[A-Za-z0-9_-]{43}$/
|
||
const HASH_RE = /^[0-9a-f]{64}$/
|
||
const LAST_SEEN_PERSIST_MS = 60_000
|
||
|
||
const sha256Hex = (value: string) => crypto.createHash('sha256').update(value).digest('hex')
|
||
|
||
// ── Pairing code ────────────────────────────────────────────────────────────
|
||
|
||
/** Upper-case and drop spaces / dashes, as the contract's comparison rule says. */
|
||
export function normalizePairCode(value: unknown): string {
|
||
return typeof value === 'string' ? value.toUpperCase().replace(/[\s-]/g, '') : ''
|
||
}
|
||
|
||
export function generatePairCode(randomBytes: (size: number) => Buffer = crypto.randomBytes): string {
|
||
// 256 % 32 === 0, so masking a byte to 5 bits is uniform over the alphabet.
|
||
const bytes = randomBytes(PAIR_CODE_LENGTH)
|
||
let code = ''
|
||
|
||
for (let i = 0; i < PAIR_CODE_LENGTH; i++) {
|
||
code += PAIR_CODE_ALPHABET[bytes[i] & 31]
|
||
}
|
||
|
||
return code
|
||
}
|
||
|
||
/** Display form only: ABCD-EFGH. */
|
||
export function formatPairCode(code: string): string {
|
||
return `${code.slice(0, 4)}-${code.slice(4)}`
|
||
}
|
||
|
||
/** hex(SHA-256("aiturk-mobile-pair-v1:" + CODE)) — the only thing the gateway sees. */
|
||
export function pairLookup(code: string): string {
|
||
return sha256Hex(`aiturk-mobile-pair-v1:${normalizePairCode(code)}`)
|
||
}
|
||
|
||
// ── Local address candidates ────────────────────────────────────────────────
|
||
|
||
export function isPrivateIpv4(address: string): boolean {
|
||
const parts = String(address).split('.')
|
||
|
||
if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part) || Number(part) > 255)) {
|
||
return false
|
||
}
|
||
|
||
const [a, b] = parts.map(Number)
|
||
|
||
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
||
}
|
||
|
||
const VIRTUAL_ADAPTER_RE =
|
||
/vethernet|virtualbox|vmware|wsl|hyper-v|loopback|docker|tailscale|zerotier|wireguard|bluetooth|vpn|\b(?:tap|tun)\d*\b/i
|
||
|
||
export interface LanInterfaceInfo {
|
||
address: string
|
||
family: string | number
|
||
internal: boolean
|
||
}
|
||
|
||
/**
|
||
* Pick up to `max` private IPv4 addresses a phone on the same LAN could reach.
|
||
* The default-route interface goes first; virtual adapters are dropped (unless
|
||
* nothing else is left and the default route itself is the only candidate).
|
||
*/
|
||
export function selectLanAddresses(
|
||
interfaces: Record<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>…]&fp=<43>&c=<code>[&r=<relayId>]`
|
||
* (v1 §2 + tunnel spec v3 §2). `u` is present only when the phone can really
|
||
* reach this computer on the LAN; `r` (a plain uuid) only when a relay
|
||
* registration exists. A QR with neither would be a code that cannot work, so
|
||
* that is refused.
|
||
*/
|
||
export function buildPairQrPayload(input: { addresses: string[]; port: number; fingerprint: string; code: string; relay?: null | string }): string {
|
||
const hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS)
|
||
const relay = isRelayId(input.relay) ? input.relay : null
|
||
|
||
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) {
|
||
throw new MobileLinkError('no_lan_address')
|
||
}
|
||
|
||
if (!hosts.length && !relay) {
|
||
throw new MobileLinkError('no_lan_address')
|
||
}
|
||
|
||
// ip:port, the fingerprint (base64url), the code and a uuid need no escaping in a query.
|
||
const parts = ['v=1', ...hosts.map(host => `u=${host}:${input.port}`), `fp=${input.fingerprint}`, `c=${input.code}`]
|
||
|
||
if (relay) {
|
||
parts.push(`r=${relay}`)
|
||
}
|
||
|
||
return `aiturk://pair?${parts.join('&')}`
|
||
}
|
||
|
||
// ── Gateway rendezvous client (contract §3; server side lives in the gateway) ─
|
||
|
||
export interface PairFetchResponse {
|
||
status: number
|
||
json: () => Promise<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; relay?: null | string }
|
||
): Promise<null | PairOffer> {
|
||
const relay = isRelayId(input.relay) ? input.relay : null
|
||
|
||
const urls = input.addresses
|
||
.filter(isPrivateIpv4)
|
||
.slice(0, MOBILE_LAN_MAX_URLS)
|
||
.map(host => `https://${host}:${input.port}`)
|
||
|
||
// Tunnel spec §1: `urls` may be empty, but then `relay` is required.
|
||
if (!urls.length && !relay) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const response = await fetcher(
|
||
`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`,
|
||
pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) })
|
||
)
|
||
|
||
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'
|
||
}
|
||
|
||
/** What Windows really has under our rule name right now. */
|
||
export type FirewallRuleInfo = { state: 'present'; ports: number[] } | { state: 'absent' } | { state: 'unknown' }
|
||
|
||
export interface MobileFirewall {
|
||
addRule: (port: number) => Promise<FirewallResult>
|
||
removeRule: () => Promise<FirewallResult>
|
||
/** Read-only, no elevation. Missing = the state cannot be read ('unknown'). */
|
||
queryRule?: () => Promise<FirewallRuleInfo>
|
||
}
|
||
|
||
/** `netsh advfirewall firewall show rule name=…` — fixed arguments, no shell, no elevation. */
|
||
export function firewallQueryCommand(systemRoot?: string) {
|
||
const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows'
|
||
|
||
return {
|
||
file: `${root}\\System32\\netsh.exe`,
|
||
args: ['advfirewall', 'firewall', 'show', 'rule', `name=${MOBILE_FIREWALL_RULE_NAME}`]
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reads the answer of the query above. netsh prints localized labels in the
|
||
* console code page, so nothing here depends on a translated word except the
|
||
* "disabled" value (No / Hayır): a rule block is recognised by its line of
|
||
* dashes, the port by `LocalPort` (not translated) or, failing that, by the
|
||
* first value that is purely a port list. Exit code 1 with no rule block is
|
||
* netsh's "No rules match the specified criteria." /
|
||
* "Belirtilen ölçütlerle eşleşen kural yok."
|
||
*/
|
||
export function parseFirewallRuleOutput(stdout: string, exitCode: null | number): FirewallRuleInfo {
|
||
const lines = String(stdout ?? '').split(/\r?\n/)
|
||
const ports = new Set<number>()
|
||
let blocks = 0
|
||
|
||
for (let index = 0; index < lines.length; index++) {
|
||
if (!/^-{20,}\s*$/.test(lines[index])) {
|
||
continue
|
||
}
|
||
|
||
blocks++
|
||
|
||
const block: string[] = []
|
||
|
||
for (let cursor = index + 1; cursor < lines.length && lines[cursor].trim() !== ''; cursor++) {
|
||
block.push(lines[cursor])
|
||
}
|
||
|
||
const value = (line: string | undefined) => (line === undefined ? '' : line.slice(line.indexOf(':') + 1).trim())
|
||
|
||
// First line of a block is Enabled / Etkin.
|
||
if (/^(?:no|hay)/i.test(value(block[0]))) {
|
||
continue
|
||
}
|
||
|
||
const portLine = block.find(line => /^LocalPort\s*:/i.test(line)) ?? block.find(line => /^\d{1,5}(?:\s*[,-]\s*\d{1,5})*$/.test(value(line)))
|
||
|
||
for (const part of value(portLine).split(',')) {
|
||
const [from, to = from] = part.split('-').map(item => Number(item.trim()))
|
||
|
||
if (Number.isInteger(from) && Number.isInteger(to) && from > 0 && to < 65536 && to - from < 64) {
|
||
for (let port = from; port <= to; port++) {
|
||
ports.add(port)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (blocks > 0) {
|
||
return { state: 'present', ports: [...ports] }
|
||
}
|
||
|
||
// No block: only an explicit "nothing matched" (exit 1 with some text) counts as absent.
|
||
return exitCode === 1 && lines.some(line => line.trim() !== '') ? { state: 'absent' } : { state: 'unknown' }
|
||
}
|
||
|
||
export type FirewallRun = (file: string, args: string[]) => Promise<{ code: null | number; stdout: string }>
|
||
|
||
const UAC_DENIED_EXIT = 77
|
||
|
||
function assertPort(port: number): number {
|
||
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
||
throw new MobileLinkError('invalid_port')
|
||
}
|
||
|
||
return port
|
||
}
|
||
|
||
/**
|
||
* Fixed command lines for the inbound rule. One elevated `netsh` call through
|
||
* `Start-Process -Verb RunAs` (a single UAC prompt). The only variable part is
|
||
* the numeric port, validated above; nothing here comes from user input and no
|
||
* shell is involved (the caller spawns PowerShell with an argument array).
|
||
*/
|
||
export function firewallCommand(action: 'add' | 'remove', port: number, systemRoot?: string) {
|
||
const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows'
|
||
|
||
const netshArgs =
|
||
action === 'add'
|
||
? `advfirewall firewall add rule name="${MOBILE_FIREWALL_RULE_NAME}" dir=in action=allow protocol=TCP localport=${assertPort(port)} profile=private,public`
|
||
: `advfirewall firewall delete rule name="${MOBILE_FIREWALL_RULE_NAME}"`
|
||
|
||
const script =
|
||
`try { $p = Start-Process -FilePath '${root}\\System32\\netsh.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru ` +
|
||
`-ArgumentList '${netshArgs}'; exit $p.ExitCode } catch { exit ${UAC_DENIED_EXIT} }`
|
||
|
||
return {
|
||
file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
|
||
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script]
|
||
}
|
||
}
|
||
|
||
export interface FirewallSpawn {
|
||
(file: string, args: string[], options: Record<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>
|
||
/** Runs the read-only query and returns exit code + stdout; without it the state is 'unknown'. */
|
||
run?: FirewallRun
|
||
}): MobileFirewall {
|
||
const run = (action: 'add' | 'remove', port: number) =>
|
||
new Promise<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),
|
||
async queryRule() {
|
||
if (deps.platform !== 'win32' || !deps.run) {
|
||
return { state: 'unknown' }
|
||
}
|
||
|
||
try {
|
||
const command = firewallQueryCommand(deps.env.SystemRoot)
|
||
const result = await deps.run(command.file, command.args)
|
||
|
||
return parseFirewallRuleOutput(result.stdout, result.code)
|
||
} catch {
|
||
return { state: 'unknown' }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── What a phone may not reach ──────────────────────────────────────────────
|
||
|
||
type DenyRule = [prefix: RegExp, scope: 'all' | 'write']
|
||
|
||
/**
|
||
* Contract §1 "Telefondan yasak". Matched case-insensitively against the
|
||
* percent-DECODED path (the backend decodes before routing, so matching the
|
||
* raw path would let `/api/%65nv` through).
|
||
*/
|
||
const DENY_RULES: readonly DenyRule[] = [
|
||
[/^\/api\/env/i, 'all'],
|
||
[/^\/api\/config\/raw/i, 'all'],
|
||
// Reading settings is fine; changing them from a phone is not.
|
||
[/^\/api\/config(?:\/|$)/i, 'write'],
|
||
[/^\/api\/ops(?:\/|$)/i, 'all'],
|
||
[/^\/api\/hermes\/update/i, 'all'],
|
||
[/^\/api\/credentials/i, 'all'],
|
||
[/^\/api\/providers/i, 'write'],
|
||
[/^\/api\/mcp(?:\/|$)/i, 'write'],
|
||
[/^\/api\/webhooks/i, 'write'],
|
||
// Plugin install / enable / disable / update / remove.
|
||
[/^\/api\/dashboard\/agent-plugins/i, 'all'],
|
||
[/^\/api\/files(?:\/|$)/i, 'all']
|
||
]
|
||
|
||
const FILES_READ_ALLOWED = new Set(['/api/files/download', '/api/files/stream'])
|
||
|
||
export function isPhoneForbidden(method: string, decodedPath: string): boolean {
|
||
const read = method === 'GET' || method === 'HEAD'
|
||
|
||
if (read && FILES_READ_ALLOWED.has(decodedPath)) {
|
||
return false
|
||
}
|
||
|
||
return DENY_RULES.some(([prefix, scope]) => prefix.test(decodedPath) && (scope === 'all' || !read))
|
||
}
|
||
|
||
/** Decoded path, or null when the request target is something we refuse to interpret. */
|
||
function decodeRequestPath(pathname: string): null | string {
|
||
let decoded: string
|
||
|
||
try {
|
||
decoded = decodeURIComponent(pathname)
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
// Control characters, backslashes, a second layer of encoding, empty and dot segments.
|
||
// eslint-disable-next-line no-control-regex
|
||
if (/[\x00-\x1f\x7f\\%]/.test(decoded) || decoded.includes('//') || /(?:^|\/)\.\.?(?:\/|$)/.test(decoded)) {
|
||
return null
|
||
}
|
||
|
||
return decoded
|
||
}
|
||
|
||
// ── The proxy ───────────────────────────────────────────────────────────────
|
||
|
||
export interface MobileBackend {
|
||
baseUrl: string
|
||
token: string
|
||
}
|
||
|
||
export interface MobileDeviceRecord {
|
||
id: string
|
||
name: string
|
||
/** hex SHA-256 of the device token; the token itself is never stored. */
|
||
tokenHash: string
|
||
createdAt: number
|
||
lastSeenAt: number
|
||
}
|
||
|
||
export interface MobileDeviceView {
|
||
id: string
|
||
name: string
|
||
createdAt: number
|
||
lastSeenAt: number
|
||
/** Open connections right now (WebSocket or in-flight request). */
|
||
live: number
|
||
}
|
||
|
||
export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
|
||
|
||
export interface LanProxyOptions {
|
||
tls: { key: string; cert: string }
|
||
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
|
||
resolveBackend: () => 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
|
||
let listeningHost: null | string = null
|
||
|
||
for (const row of options.devices ?? []) {
|
||
devices.set(row.tokenHash, { ...row, sockets: new Set(), persistedSeenAt: row.lastSeenAt })
|
||
}
|
||
|
||
const records = (): MobileDeviceRecord[] =>
|
||
[...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ id, name, tokenHash, createdAt, lastSeenAt }))
|
||
|
||
const log = (method: string | undefined, path: string, status: number) => {
|
||
if (options.log) {
|
||
// Path only (no query), bounded; a token-shaped segment is masked defensively.
|
||
const safe = path.slice(0, 160).replace(/amd_[A-Za-z0-9_-]*/g, '[redacted]')
|
||
|
||
options.log(`${String(method ?? '').slice(0, 10)} ${safe} ${status}`)
|
||
}
|
||
}
|
||
|
||
const reply = (req: http.IncomingMessage, res: http.ServerResponse, path: string, status: number, body: unknown) => {
|
||
log(req.method, path, status)
|
||
|
||
if (res.headersSent) {
|
||
res.destroy()
|
||
|
||
return
|
||
}
|
||
|
||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
||
res.end(JSON.stringify(body))
|
||
}
|
||
|
||
function credential(req: http.IncomingMessage, url: URL): string {
|
||
const auth = req.headers.authorization
|
||
|
||
if (typeof auth === 'string' && /^bearer /i.test(auth)) {
|
||
return auth.slice(7).trim()
|
||
}
|
||
|
||
const header = req.headers['x-hermes-session-token']
|
||
|
||
if (typeof header === 'string' && header) {
|
||
return header
|
||
}
|
||
|
||
return url.searchParams.get('token') ?? ''
|
||
}
|
||
|
||
function authenticate(req: http.IncomingMessage, url: URL): LiveDevice | undefined {
|
||
const token = credential(req, url)
|
||
|
||
// Shape check first: the backend's own session token is not `amd_…`.
|
||
if (!DEVICE_TOKEN_RE.test(token)) {
|
||
return undefined
|
||
}
|
||
|
||
const device = devices.get(sha256Hex(token))
|
||
|
||
if (device) {
|
||
device.lastSeenAt = now()
|
||
|
||
if (device.lastSeenAt - device.persistedSeenAt >= LAST_SEEN_PERSIST_MS) {
|
||
device.persistedSeenAt = device.lastSeenAt
|
||
void Promise.resolve(options.onDevicesChanged?.(records())).catch(() => undefined)
|
||
}
|
||
}
|
||
|
||
return device
|
||
}
|
||
|
||
async function localBackend(): 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
|
||
},
|
||
/** Address the socket is bound to right now, or null. */
|
||
get host() {
|
||
return listeningHost
|
||
},
|
||
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
|
||
listeningHost = host
|
||
resolve(listeningPort)
|
||
})
|
||
})
|
||
},
|
||
stop(): Promise<void> {
|
||
return new Promise(resolve => {
|
||
if (listeningPort === null) {
|
||
resolve()
|
||
|
||
return
|
||
}
|
||
|
||
listeningPort = null
|
||
listeningHost = null
|
||
|
||
for (const socket of sockets) {
|
||
socket.destroy()
|
||
}
|
||
|
||
server.close(() => resolve())
|
||
})
|
||
},
|
||
/** Starts a pairing session; any previous one is dropped. The code is returned once. */
|
||
beginPairing(): { code: string; expiresAt: number } {
|
||
const code = generatePairCode(randomBytes)
|
||
|
||
pairing = { codeHash: crypto.createHash('sha256').update(code).digest(), expiresAt: now() + codeTtlMs, attempts: 0 }
|
||
|
||
return { code, expiresAt: pairing.expiresAt }
|
||
},
|
||
cancelPairing(): boolean {
|
||
if (!pairing) {
|
||
return false
|
||
}
|
||
|
||
endPairing('cancelled')
|
||
|
||
return true
|
||
},
|
||
pairingOpen,
|
||
records,
|
||
devices: (): MobileDeviceView[] =>
|
||
[...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ id, name, createdAt, lastSeenAt, live: live.size })),
|
||
/** Removes the device: its token turns 401 and its open connections are closed. */
|
||
async revoke(id: string): Promise<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[]
|
||
/**
|
||
* "Aynı Wi-Fi'da doğrudan bağlantıya da izin ver". Off by default (also for
|
||
* records written by beta.15, which has no such field): the proxy then binds
|
||
* to 127.0.0.1 only and phones come in through the relay.
|
||
*/
|
||
lanDirect: boolean
|
||
/** Relay registration id (not a secret; the host token is in the vault). */
|
||
relayId: null | string
|
||
/** The relay registration was replaced while phones were paired: they must pair again. */
|
||
relayReissued: boolean
|
||
}
|
||
|
||
export interface MobileLinkStore {
|
||
load: () => Promise<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,
|
||
lanDirect: row.lanDirect === true,
|
||
relayId: typeof row.relayId === 'string' && UUID_RE.test(row.relayId) ? row.relayId.toLowerCase() : null,
|
||
relayReissued: row.relayReissued === true
|
||
}
|
||
}
|
||
|
||
// ── The controller the window and main.ts talk to ───────────────────────────
|
||
|
||
export type MobileLinkErrorCode =
|
||
| 'remote_mode'
|
||
| 'vault_unavailable'
|
||
| 'not_enabled'
|
||
| 'no_lan_address'
|
||
| 'relay_unavailable'
|
||
| 'port_unavailable'
|
||
| 'invalid_port'
|
||
| 'invalid_request'
|
||
| 'unsupported'
|
||
|
||
/** Message is ONLY a fixed code, so nothing secret can ride an error to the UI or the log. */
|
||
export class MobileLinkError extends Error {
|
||
code: MobileLinkErrorCode
|
||
|
||
constructor(code: MobileLinkErrorCode) {
|
||
super(`aiturk-mobile:${code}`)
|
||
this.name = 'MobileLinkError'
|
||
this.code = code
|
||
}
|
||
}
|
||
|
||
export type MobileFirewallState = 'ok' | 'needed' | 'denied' | 'failed' | 'not-needed'
|
||
/** The rule as Windows reports it right now (never the stored guess). */
|
||
export type MobileLanFirewall = 'allowed' | 'missing' | 'unknown'
|
||
|
||
export interface MobileLinkState {
|
||
enabled: boolean
|
||
/** The local proxy has a socket (loopback or LAN). Says nothing about reachability from a phone. */
|
||
listening: boolean
|
||
port: null | number
|
||
/** IDE is in remote-gateway mode: the feature is refused. */
|
||
remote: boolean
|
||
/** Outcome of the last "İzin ver" attempt on top of the real rule state. */
|
||
firewall: MobileFirewallState
|
||
/** Connection through turkservis.online. Never carries the host token. */
|
||
relay: {
|
||
status: RelayStatus
|
||
/** Fixed code while `failed`. */
|
||
code: null | RelayFailureCode
|
||
/** A registration exists, so a QR / code can carry the relay id. */
|
||
ready: boolean
|
||
/** The registration was replaced: phones paired before must be paired again. */
|
||
reissued: boolean
|
||
}
|
||
/** Direct connection on the same Wi-Fi. */
|
||
lan: {
|
||
/** The user allowed it ("Aynı Wi-Fi'da doğrudan bağlantıya da izin ver"). */
|
||
direct: boolean
|
||
/** A socket is REALLY bound to the LAN right now (not just loopback). */
|
||
listening: boolean
|
||
port: null | number
|
||
firewall: MobileLanFirewall
|
||
}
|
||
pcName: string
|
||
pairing: null | {
|
||
/** Raw 8 characters; the window shows it as ABCD-EFGH. */
|
||
code: string
|
||
qr: string
|
||
expiresAt: number
|
||
/** Gateway rendezvous for the "type the code" path. */
|
||
codePath: 'pending' | 'ready' | 'unavailable'
|
||
/**
|
||
* "Güvenlik kodu": first 8 characters of the certificate fingerprint. The
|
||
* phone shows the same 8 characters; comparing them closes the code path's
|
||
* trust in the gateway (tunnel spec §4).
|
||
*/
|
||
securityCode: string
|
||
}
|
||
devices: MobileDeviceView[]
|
||
}
|
||
|
||
export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
||
|
||
export interface MobileLinkDeps {
|
||
vault: MobileLanVault
|
||
store: MobileLinkStore
|
||
resolveBackend: () => Promise<MobileBackend>
|
||
isRemoteMode: () => boolean
|
||
firewall: MobileFirewall
|
||
fetch: PairFetch
|
||
/** Private IPv4 candidates, default route first. */
|
||
addresses: () => Promise<string[]>
|
||
version: string
|
||
pcName: string
|
||
platform: string
|
||
/** Bind used while direct LAN access is allowed (default 0.0.0.0). */
|
||
listenHost?: string
|
||
/** Bind used otherwise (default 127.0.0.1). */
|
||
loopbackHost?: string
|
||
/** Candidate ports, tried in order when none is stored yet (or the stored one is taken). */
|
||
ports?: number[]
|
||
/**
|
||
* WebSocket client class that can send request headers (the `ws` package),
|
||
* for the outbound host socket. Without it (and without `relayConnect`) the
|
||
* relay stays off.
|
||
*/
|
||
WebSocket?: RelayWebSocketCtor
|
||
/** Opens the host socket; tests point it at a fake relay on 127.0.0.1. */
|
||
relayConnect?: RelayConnect
|
||
now?: () => number
|
||
randomBytes?: (size: number) => Buffer
|
||
randomUUID?: () => string
|
||
log?: (line: string) => void
|
||
setTimer?: (fn: () => void, ms: number) => unknown
|
||
clearTimer?: (handle: unknown) => void
|
||
}
|
||
|
||
function defaultPorts(): number[] {
|
||
const ports: number[] = []
|
||
|
||
for (let port = MOBILE_LAN_DEFAULT_PORT; port <= MOBILE_LAN_LAST_PORT; port++) {
|
||
ports.push(port)
|
||
}
|
||
|
||
return ports
|
||
}
|
||
|
||
/** Find a free port by binding and releasing it; nothing is ever accepted on it. */
|
||
function probeFreePort(port: number, host: string): Promise<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))
|
||
})
|
||
})
|
||
}
|
||
|
||
interface PairSession {
|
||
code: string
|
||
qr: string
|
||
expiresAt: number
|
||
codePath: 'pending' | 'ready' | 'unavailable'
|
||
offer: null | PairOffer
|
||
timer: unknown
|
||
addresses: string[]
|
||
port: number
|
||
fingerprint: string
|
||
}
|
||
|
||
export function createMobileLink(deps: MobileLinkDeps) {
|
||
const now = deps.now ?? Date.now
|
||
const lanHost = deps.listenHost ?? MOBILE_LAN_DIRECT_HOST
|
||
const loopbackHost = deps.loopbackHost ?? MOBILE_LOOPBACK_HOST
|
||
const ports = deps.ports ?? defaultPorts()
|
||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||
const listeners = new Set<(event: MobileLinkEvent) => void>()
|
||
const pcName = (deps.pcName || 'AITURK IDE').slice(0, 60)
|
||
const windows = deps.platform === 'win32'
|
||
|
||
let stored: MobileLinkStored | null = null
|
||
let identity: MobileLanIdentity | null = null
|
||
let proxy: LanProxy | null = null
|
||
let relay: RelayClient | null = null
|
||
let firewallFailure: null | 'denied' | 'failed' = null
|
||
/** Last answer of the real rule query. */
|
||
let rule: FirewallRuleInfo = { state: 'unknown' }
|
||
let session: null | PairSession = null
|
||
let queue: Promise<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 })
|
||
}
|
||
}
|
||
|
||
/** Ask Windows (read-only, no elevation). The stored `firewallPort` is never trusted. */
|
||
async function refreshFirewall() {
|
||
if (!windows) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
rule = (await deps.firewall.queryRule?.()) ?? { state: 'unknown' }
|
||
} catch {
|
||
rule = { state: 'unknown' }
|
||
}
|
||
}
|
||
|
||
function lanFirewall(data: MobileLinkStored): MobileLanFirewall {
|
||
if (!windows) {
|
||
return 'allowed'
|
||
}
|
||
|
||
if (rule.state === 'unknown') {
|
||
return 'unknown'
|
||
}
|
||
|
||
return rule.state === 'present' && data.port !== null && rule.ports.includes(data.port) ? 'allowed' : 'missing'
|
||
}
|
||
|
||
function firewallState(data: MobileLinkStored): MobileFirewallState {
|
||
if (!windows) {
|
||
return 'not-needed'
|
||
}
|
||
|
||
return lanFirewall(data) === 'allowed' ? 'ok' : (firewallFailure ?? 'needed')
|
||
}
|
||
|
||
/**
|
||
* Bind to the LAN only when the user allowed it AND Windows is not known to
|
||
* block the port (listening on all interfaces without a rule would make
|
||
* Windows raise its own permission dialog).
|
||
*/
|
||
const directWanted = (data: MobileLinkStored) => data.lanDirect && lanFirewall(data) !== 'missing'
|
||
const bindHost = (data: MobileLinkStored) => (directWanted(data) ? lanHost : loopbackHost)
|
||
|
||
function snapshot(data: MobileLinkStored): MobileLinkState {
|
||
const listening = Boolean(proxy?.listening)
|
||
const relayState = relay?.state() ?? { status: 'off' as RelayStatus, code: null }
|
||
|
||
return {
|
||
enabled: data.enabled,
|
||
listening,
|
||
port: data.port,
|
||
remote: deps.isRemoteMode(),
|
||
firewall: firewallState(data),
|
||
relay: { status: relayState.status, code: relayState.code, ready: data.relayId !== null, reissued: data.relayReissued },
|
||
lan: {
|
||
direct: data.lanDirect,
|
||
listening: listening && data.lanDirect && proxy?.host === lanHost,
|
||
port: data.port,
|
||
firewall: lanFirewall(data)
|
||
},
|
||
pcName,
|
||
pairing: session
|
||
? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath, securityCode: session.fingerprint.slice(0, 8) }
|
||
: null,
|
||
devices: proxy
|
||
? proxy.devices()
|
||
: data.devices.map(({ id, name, createdAt, lastSeenAt }) => ({ id, name, createdAt, lastSeenAt, live: 0 }))
|
||
}
|
||
}
|
||
|
||
function emit(extra?: { paired: string }) {
|
||
if (!stored) {
|
||
return
|
||
}
|
||
|
||
const state = snapshot(stored)
|
||
const event: MobileLinkEvent = extra ? { type: 'paired', name: extra.paired, state } : { type: 'state', state }
|
||
|
||
for (const listener of listeners) {
|
||
try {
|
||
listener(event)
|
||
} catch {
|
||
// a broken listener must not break the proxy
|
||
}
|
||
}
|
||
}
|
||
|
||
function dropSession(cancelOffer: boolean) {
|
||
const ended = session
|
||
|
||
session = null
|
||
|
||
if (ended) {
|
||
clearTimer(ended.timer)
|
||
|
||
if (cancelOffer && ended.offer) {
|
||
void cancelPairOffer(deps.fetch, ended.offer)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The relay registration changed while a QR is on screen: show the current address. */
|
||
function refreshSessionQr(data: MobileLinkStored) {
|
||
if (!session) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
session.qr = buildPairQrPayload({
|
||
addresses: session.addresses,
|
||
port: session.port,
|
||
fingerprint: session.fingerprint,
|
||
code: session.code,
|
||
relay: data.relayId
|
||
})
|
||
} catch {
|
||
// neither a LAN address nor a relay left; the old payload stays until pairing ends
|
||
}
|
||
}
|
||
|
||
async function ensureProxy(data: MobileLinkStored): Promise<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)
|
||
|
||
if (reason === 'claimed') {
|
||
// This phone was paired against the current relay address.
|
||
data.relayReissued = false
|
||
}
|
||
|
||
void serial(() => syncListening(data)).then(
|
||
() => emit(reason === 'claimed' && device ? { paired: device.name } : undefined),
|
||
() => emit()
|
||
)
|
||
}
|
||
})
|
||
|
||
return proxy
|
||
}
|
||
|
||
/**
|
||
* The tunnel client. It only copies bytes between the gateway and a plain TCP
|
||
* connection to the proxy; TLS stays between the phone and the proxy. null
|
||
* when no header-capable WebSocket client was injected.
|
||
*/
|
||
function ensureRelay(data: MobileLinkStored): null | RelayClient {
|
||
if (relay || (!deps.WebSocket && !deps.relayConnect)) {
|
||
return relay
|
||
}
|
||
|
||
const WebSocketCtor = deps.WebSocket
|
||
|
||
relay = createRelayClient({
|
||
connect:
|
||
deps.relayConnect ??
|
||
((url, headers) =>
|
||
new (WebSocketCtor as RelayWebSocketCtor)(url, {
|
||
headers,
|
||
handshakeTimeout: 20_000,
|
||
perMessageDeflate: false,
|
||
followRedirects: false,
|
||
maxPayload: RELAY_MAX_FRAME_BYTES
|
||
})),
|
||
fetch: deps.fetch,
|
||
dial: () => {
|
||
const port = proxy?.port ?? null
|
||
const host = proxy?.host ?? null
|
||
|
||
if (port === null || host === null) {
|
||
return null
|
||
}
|
||
|
||
// A socket bound to every interface is reached on loopback; anything else on its own address.
|
||
return net.connect({ port, host: host === MOBILE_LAN_DIRECT_HOST ? MOBILE_LOOPBACK_HOST : host, noDelay: true })
|
||
},
|
||
credentials: {
|
||
// relayId on disk, hostToken only in the vault; both must describe the same registration.
|
||
async load() {
|
||
if (!data.relayId) {
|
||
return null
|
||
}
|
||
|
||
const row = (await deps.vault.load())?.relay
|
||
|
||
return isRelayCredentials(row) && row.relayId === data.relayId ? { relayId: row.relayId, hostToken: row.hostToken } : null
|
||
},
|
||
async save(credentials, info) {
|
||
const previous = data.relayId
|
||
|
||
await deps.vault.save({ ...((await deps.vault.load()) ?? {}), relay: { relayId: credentials.relayId, hostToken: credentials.hostToken } })
|
||
data.relayId = credentials.relayId
|
||
|
||
// The public address the paired phones stored is gone with the old registration.
|
||
if ((info.reissued || (previous !== null && previous !== credentials.relayId)) && data.devices.length > 0) {
|
||
data.relayReissued = true
|
||
}
|
||
|
||
await persist()
|
||
refreshSessionQr(data)
|
||
emit()
|
||
},
|
||
async clear() {
|
||
if (data.relayId !== null && data.devices.length > 0) {
|
||
data.relayReissued = true
|
||
}
|
||
|
||
data.relayId = null
|
||
await persist()
|
||
}
|
||
},
|
||
setTimer,
|
||
clearTimer,
|
||
log: deps.log,
|
||
onState: () => emit()
|
||
})
|
||
|
||
return relay
|
||
}
|
||
|
||
async function ensurePort(data: MobileLinkStored): Promise<number> {
|
||
if (data.port !== null) {
|
||
return data.port
|
||
}
|
||
|
||
for (const candidate of ports) {
|
||
const free = await probeFreePort(candidate, bindHost(data))
|
||
|
||
if (free !== null) {
|
||
data.port = free
|
||
|
||
return free
|
||
}
|
||
}
|
||
|
||
throw new MobileLinkError('port_unavailable')
|
||
}
|
||
|
||
async function startListening(data: MobileLinkStored, instance: LanProxy) {
|
||
const host = bindHost(data)
|
||
|
||
if (instance.listening) {
|
||
if (instance.host === host) {
|
||
return
|
||
}
|
||
|
||
// "Doğrudan bağlantı" was switched: move the socket between loopback and the LAN.
|
||
await instance.stop()
|
||
}
|
||
|
||
const candidates = data.port !== null ? [data.port, ...ports.filter(port => port !== data.port)] : ports
|
||
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const actual = await instance.listen(candidate, host)
|
||
|
||
if (data.port !== actual) {
|
||
// The rule (if any) was for another port; the UI offers to add it again.
|
||
data.port = actual
|
||
await persist()
|
||
}
|
||
|
||
return
|
||
} catch {
|
||
// taken; try the next one
|
||
}
|
||
}
|
||
|
||
throw new MobileLinkError('port_unavailable')
|
||
}
|
||
|
||
/**
|
||
* Listen — and keep the relay socket open — only while enabled AND (a phone
|
||
* is paired OR pairing is in progress). Both follow the same condition.
|
||
*/
|
||
async function syncListening(data: MobileLinkStored) {
|
||
const wanted = () => !stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null)
|
||
|
||
// ensureProxy may drop phones paired against a replaced certificate, so ask again after it.
|
||
const instance = wanted() ? await ensureProxy(data) : proxy
|
||
|
||
if (!wanted()) {
|
||
relay?.stop()
|
||
await instance?.stop()
|
||
|
||
return
|
||
}
|
||
|
||
await startListening(data, instance as LanProxy)
|
||
ensureRelay(data)?.start()
|
||
}
|
||
|
||
/** Cancels a pairing whose QR would no longer describe how the phone can connect. */
|
||
function endPairingQuietly() {
|
||
proxy?.cancelPairing()
|
||
dropSession(true)
|
||
}
|
||
|
||
const api = {
|
||
onEvent(listener: (event: MobileLinkEvent) => void): () => void {
|
||
listeners.add(listener)
|
||
|
||
return () => listeners.delete(listener)
|
||
},
|
||
|
||
state: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
await refreshFirewall()
|
||
|
||
// The rule was removed (or added back) outside the IDE: follow it.
|
||
if (proxy?.listening && proxy.host !== bindHost(data)) {
|
||
await syncListening(data)
|
||
}
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** App start: resume when the feature is on and a phone is paired. No UAC, no pairing. */
|
||
start: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
stopped = false
|
||
|
||
if (data.enabled && data.devices.length > 0 && !deps.isRemoteMode()) {
|
||
await refreshFirewall()
|
||
await syncListening(data)
|
||
}
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** App quit / feature teardown: close every socket. Stored state is kept. */
|
||
stop: () =>
|
||
serial(async () => {
|
||
stopped = true
|
||
proxy?.cancelPairing()
|
||
dropSession(true)
|
||
relay?.stop()
|
||
await proxy?.stop()
|
||
}),
|
||
|
||
/** Turns the feature on. Never touches the firewall and never raises a UAC prompt. */
|
||
enable: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
stopped = false
|
||
await ensureProxy(data)
|
||
await refreshFirewall()
|
||
await ensurePort(data)
|
||
data.enabled = true
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** Stops listening and the relay. The firewall rule and the paired phones are kept. */
|
||
disable: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
endPairingQuietly()
|
||
data.enabled = false
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
pairBegin: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
if (!data.enabled) {
|
||
throw new MobileLinkError('not_enabled')
|
||
}
|
||
|
||
const instance = await ensureProxy(data)
|
||
|
||
await refreshFirewall()
|
||
|
||
// Stored registration, or one `POST /sessions` (bounded by the fetch timeout).
|
||
const relayUrl = (await ensureRelay(data)?.ensureRegistered()) ?? null
|
||
|
||
// Without the relay AND without direct LAN access there is no way in: show no dead code.
|
||
if (!relayUrl && !data.lanDirect) {
|
||
throw new MobileLinkError('relay_unavailable')
|
||
}
|
||
|
||
// LAN candidates only when the phone could really reach them.
|
||
const addresses = directWanted(data) ? (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) : []
|
||
|
||
if (!relayUrl && !addresses.length) {
|
||
throw new MobileLinkError('no_lan_address')
|
||
}
|
||
|
||
dropSession(true)
|
||
await startListening(data, instance)
|
||
|
||
const port = data.port as number
|
||
const fingerprint = (identity as MobileLanIdentity).fingerprint
|
||
const { code, expiresAt } = instance.beginPairing()
|
||
|
||
const current: PairSession = {
|
||
code,
|
||
qr: buildPairQrPayload({ addresses, port, fingerprint, code, relay: relayUrl }),
|
||
expiresAt,
|
||
codePath: 'pending',
|
||
offer: null,
|
||
// The proxy notices expiry lazily (on a request); this closes the socket on time.
|
||
timer: setTimer(() => {
|
||
instance.pairingOpen()
|
||
}, Math.max(0, expiresAt - now()) + 50),
|
||
addresses,
|
||
port,
|
||
fingerprint
|
||
}
|
||
|
||
session = current
|
||
ensureRelay(data)?.start()
|
||
|
||
// Not awaited: the QR must not wait for the gateway.
|
||
void publishPairOffer(deps.fetch, { code, addresses, port, fingerprint, relay: relayUrl }).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()
|
||
|
||
endPairingQuietly()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
devices: () => serial(async () => snapshot(await load()).devices),
|
||
|
||
deviceRevoke: (id: unknown) =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (typeof id !== 'string' || !UUID_RE.test(id)) {
|
||
throw new MobileLinkError('invalid_request')
|
||
}
|
||
|
||
if (proxy) {
|
||
await proxy.revoke(id)
|
||
} else {
|
||
data.devices = data.devices.filter(device => device.id !== id)
|
||
await persist()
|
||
}
|
||
|
||
if (data.devices.length === 0 && data.relayReissued) {
|
||
data.relayReissued = false
|
||
await persist()
|
||
}
|
||
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/**
|
||
* "Aynı Wi-Fi'da doğrudan bağlantıya da izin ver": the ONLY place that adds
|
||
* the Windows Firewall rule (one UAC prompt, asked because the user pressed
|
||
* the button). Direct access is switched on only when the rule is in place.
|
||
*/
|
||
lanAllow: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (!data.enabled) {
|
||
throw new MobileLinkError('not_enabled')
|
||
}
|
||
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
await ensureProxy(data)
|
||
await ensurePort(data)
|
||
await refreshFirewall()
|
||
|
||
if (windows && lanFirewall(data) !== 'allowed') {
|
||
const result = await deps.firewall.addRule(data.port as number)
|
||
|
||
if (!result.ok) {
|
||
firewallFailure = result.reason ?? 'failed'
|
||
await persist()
|
||
|
||
return snapshot(data)
|
||
}
|
||
|
||
data.firewallPort = data.port
|
||
await refreshFirewall()
|
||
}
|
||
|
||
firewallFailure = null
|
||
data.lanDirect = true
|
||
// A QR on screen was built without LAN addresses.
|
||
endPairingQuietly()
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/**
|
||
* "Röle kimliğini yenile": new relay registration, every paired phone is
|
||
* dropped (its stored address and, if the token leaked, its path are dead)
|
||
* and the window tells the user to pair again.
|
||
*/
|
||
relayRenew: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (!data.enabled) {
|
||
throw new MobileLinkError('not_enabled')
|
||
}
|
||
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
const instance = await ensureProxy(data)
|
||
const client = ensureRelay(data)
|
||
|
||
endPairingQuietly()
|
||
|
||
if (!client || !(await client.renew())) {
|
||
throw new MobileLinkError('relay_unavailable')
|
||
}
|
||
|
||
const hadPhones = data.devices.length > 0
|
||
|
||
for (const device of instance.devices()) {
|
||
await instance.revoke(device.id)
|
||
}
|
||
|
||
data.relayReissued = hadPhones
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** Back to loopback only. The firewall rule is left alone (no UAC). */
|
||
lanDisallow: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
data.lanDirect = false
|
||
firewallFailure = null
|
||
endPairingQuietly()
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** Removes the rule (one UAC prompt) and, with it, direct LAN access. */
|
||
firewallRemove: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (windows) {
|
||
const result = await deps.firewall.removeRule()
|
||
|
||
if (result.ok) {
|
||
data.firewallPort = null
|
||
data.lanDirect = false
|
||
firewallFailure = null
|
||
endPairingQuietly()
|
||
await persist()
|
||
await refreshFirewall()
|
||
await syncListening(data)
|
||
} else {
|
||
firewallFailure = result.reason ?? 'failed'
|
||
}
|
||
}
|
||
|
||
return snapshot(data)
|
||
})
|
||
}
|
||
|
||
// Kept for callers written against beta.15.
|
||
return { ...api, firewallAllow: api.lanAllow, firewallRetry: api.lanAllow }
|
||
}
|
||
|
||
export type MobileLink = ReturnType<typeof createMobileLink>
|