2352 lines
71 KiB
TypeScript
2352 lines
71 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>
|
||
|
||
/**
|
||
* A published rendezvous record. Both fields are null when the record is known
|
||
* to exist but its answer never reached us (an earlier try failed on the wire
|
||
* and the next one got 409): the code path works, there is just nothing to
|
||
* cancel — the record expires on its own after five minutes.
|
||
*/
|
||
export interface PairOffer {
|
||
offerId: null | string
|
||
cancelToken: null | string
|
||
}
|
||
|
||
export const PAIR_OFFER_MAX_TRIES = 4
|
||
/** Waits between the tries: ~0.5 s, 1.5 s, 3 s. */
|
||
export const PAIR_OFFER_RETRY_DELAYS_MS: readonly number[] = [500, 1500, 3000]
|
||
|
||
export interface PairOfferOptions {
|
||
/** Injected wait (the controller passes its own timer). Default: a real timer. */
|
||
sleep?: (ms: number) => Promise<void>
|
||
delaysMs?: readonly number[]
|
||
maxTries?: number
|
||
/** true = the pairing this record belongs to is over: stop trying. */
|
||
cancelled?: () => boolean
|
||
/** "offer published" / "offer failed after N tries" only — never the code, the lookup or a token. */
|
||
log?: (line: string) => void
|
||
/**
|
||
* An earlier publish for the SAME code may have reached the gateway (its
|
||
* answer was lost), so a 409 on the very first try also means "it is there".
|
||
*/
|
||
earlierTryMayHaveLanded?: boolean
|
||
/** Called when a try ended without a usable answer (thrown error / 5xx), i.e. it may have been stored. */
|
||
onMayHaveLanded?: () => void
|
||
}
|
||
|
||
function pairFetchInit(body: unknown, headers: Record<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 when it could not be published —
|
||
* the QR path keeps working and the UI shows "kodla bağlanma kullanılamıyor".
|
||
*
|
||
* Up to four tries. A long-idle process can lose its first request on a dead
|
||
* pooled keep-alive connection, and one lost request must not close the code
|
||
* path for the whole five minutes:
|
||
* - a thrown network error, 5xx and 429 are tried again;
|
||
* - 422 and every other answer are final (the request itself is wrong);
|
||
* - 409 after a try that failed on the wire / with 5xx means that try DID
|
||
* land and only its answer was lost: the record exists, the code path is
|
||
* ready (no cancel token is known for it).
|
||
*/
|
||
export async function publishPairOffer(
|
||
fetcher: PairFetch,
|
||
input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string },
|
||
options: PairOfferOptions = {}
|
||
): Promise<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
|
||
}
|
||
|
||
const sleep = options.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
|
||
const delays = options.delaysMs ?? PAIR_OFFER_RETRY_DELAYS_MS
|
||
const maxTries = Math.max(1, options.maxTries ?? PAIR_OFFER_MAX_TRIES)
|
||
const cancelled = options.cancelled ?? (() => false)
|
||
const body = { lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) }
|
||
let mayHaveLanded = options.earlierTryMayHaveLanded === true
|
||
let tries = 0
|
||
|
||
const failed = () => {
|
||
options.log?.(`offer failed after ${tries} ${tries === 1 ? 'try' : 'tries'}`)
|
||
|
||
return null
|
||
}
|
||
|
||
const published = (offer: PairOffer) => {
|
||
options.log?.('offer published')
|
||
|
||
return offer
|
||
}
|
||
|
||
while (tries < maxTries) {
|
||
if (cancelled()) {
|
||
return null
|
||
}
|
||
|
||
tries++
|
||
|
||
let status: number
|
||
let data: null | Record<string, unknown> = null
|
||
|
||
try {
|
||
// A fresh init per try: the timeout signal must not be shared between tries.
|
||
const response = await fetcher(`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, pairFetchInit(body))
|
||
|
||
status = response.status
|
||
|
||
if (status === 201) {
|
||
data = (await response.json()) as null | Record<string, unknown>
|
||
}
|
||
} catch {
|
||
// No (complete) answer: the request may or may not have been stored.
|
||
status = 0
|
||
}
|
||
|
||
if (status === 201) {
|
||
if (
|
||
!data ||
|
||
typeof data.offerId !== 'string' ||
|
||
!UUID_RE.test(data.offerId) ||
|
||
typeof data.cancelToken !== 'string' ||
|
||
!CANCEL_TOKEN_RE.test(data.cancelToken)
|
||
) {
|
||
return failed()
|
||
}
|
||
|
||
return published({ offerId: data.offerId, cancelToken: data.cancelToken })
|
||
}
|
||
|
||
if (status === 409 && mayHaveLanded) {
|
||
return published({ offerId: null, cancelToken: null })
|
||
}
|
||
|
||
const transient = status === 0 || status === 429 || (status >= 500 && status <= 599)
|
||
|
||
if (!transient) {
|
||
return failed()
|
||
}
|
||
|
||
if (status !== 429) {
|
||
mayHaveLanded = true
|
||
options.onMayHaveLanded?.()
|
||
}
|
||
|
||
if (tries < maxTries) {
|
||
await sleep(delays[Math.min(tries - 1, delays.length - 1)] ?? 0)
|
||
}
|
||
}
|
||
|
||
return cancelled() ? null : failed()
|
||
}
|
||
|
||
/** Best effort; the record expires on its own after five minutes anyway. No token = nothing to cancel. */
|
||
export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise<void> {
|
||
if (!offer.cancelToken) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await fetcher(
|
||
`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/cancel`,
|
||
pairFetchInit({}, { Authorization: `Bearer ${offer.cancelToken}` })
|
||
)
|
||
} catch {
|
||
// nothing to do
|
||
}
|
||
}
|
||
|
||
// ── Windows Firewall ────────────────────────────────────────────────────────
|
||
|
||
export interface FirewallResult {
|
||
ok: boolean
|
||
/** Set when `ok` is false. `denied` = the user refused the UAC prompt. */
|
||
reason?: 'denied' | 'failed'
|
||
}
|
||
|
||
/** What Windows really has under our rule name right now. */
|
||
export type FirewallRuleInfo = { state: 'present'; ports: number[] } | { state: 'absent' } | { state: 'unknown' }
|
||
|
||
export interface MobileFirewall {
|
||
addRule: (port: number) => Promise<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: (profile: string) => Promise<MobileBackend>
|
||
/** Resolution only, never the duration of an agent/tool/cron execution. */
|
||
backendResolveTimeoutMs?: number
|
||
version: string
|
||
pcName: string
|
||
devices?: MobileDeviceRecord[]
|
||
now?: () => number
|
||
randomBytes?: (size: number) => Buffer
|
||
randomUUID?: () => string
|
||
/** method + path + status only. */
|
||
log?: (line: string) => void
|
||
/** Persist; awaited before a claim answers. Throwing fails the claim. */
|
||
onDevicesChanged?: (devices: MobileDeviceRecord[]) => Promise<void> | 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(url: URL): Promise<null | { target: URL; token: string }> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined
|
||
try {
|
||
// A paired phone controls this machine, but its selected profile is NOT
|
||
// the desktop window's selection. `all` is an aggregate read on the root
|
||
// dashboard, not a profile to spawn. Keep the original query downstream.
|
||
const selected = url.searchParams.get('profile')?.trim().toLowerCase() || 'default'
|
||
const profile = selected === 'all' ? 'default' : selected
|
||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) return null
|
||
const backend = await Promise.race([
|
||
options.resolveBackend(profile),
|
||
new Promise<never>((_resolve, reject) => {
|
||
timer = setTimeout(() => reject(new Error('backend_not_ready')), options.backendResolveTimeoutMs ?? 8000)
|
||
})
|
||
])
|
||
const target = new URL(backend.baseUrl)
|
||
|
||
// Never forward a phone to anything but the loopback backend.
|
||
if (target.protocol !== 'http:' || !LOOPBACK_HOSTS.has(target.hostname) || !backend.token) {
|
||
return null
|
||
}
|
||
|
||
return { target, token: backend.token }
|
||
} catch {
|
||
return null
|
||
} finally {
|
||
if (timer) clearTimeout(timer)
|
||
}
|
||
}
|
||
|
||
function endPairing(reason: PairingEndReason, device?: MobileDeviceRecord) {
|
||
pairing = null
|
||
options.onPairingEnded?.(reason, device)
|
||
}
|
||
|
||
function pairingOpen(): boolean {
|
||
if (pairing && pairing.expiresAt <= now()) {
|
||
endPairing('expired')
|
||
}
|
||
|
||
return pairing !== null
|
||
}
|
||
|
||
/** Body text, or null when it is larger than the limit. */
|
||
async function readClaimBody(req: http.IncomingMessage): Promise<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(url)
|
||
|
||
if (res.destroyed) return
|
||
|
||
if (!backend) {
|
||
return reply(req, res, path, 503, { error: 'backend_unavailable' })
|
||
}
|
||
|
||
// Revoked while the backend was being resolved.
|
||
if (!devices.has(device.tokenHash)) {
|
||
return reply(req, res, path, 401, { error: 'unauthorized' })
|
||
}
|
||
|
||
forward(req, res, url, path, device, backend)
|
||
}
|
||
|
||
async function onUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer) {
|
||
const refuse = (path: string, status: number, error: string) => {
|
||
const body = JSON.stringify({ error })
|
||
|
||
log(req.method, path, status)
|
||
socket.end(
|
||
`HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? 'Error'}\r\nConnection: close\r\nContent-Type: application/json\r\n` +
|
||
`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`
|
||
)
|
||
}
|
||
|
||
socket.on('error', () => socket.destroy())
|
||
|
||
const raw = req.url ?? ''
|
||
const url = raw.startsWith('/') && !raw.startsWith('//') ? new URL(raw, 'http://proxy.invalid') : null
|
||
const path = url ? decodeRequestPath(url.pathname) : null
|
||
|
||
if (!url || path === null) {
|
||
return refuse('(invalid)', 400, 'invalid_request')
|
||
}
|
||
|
||
if (!path.startsWith('/api/')) {
|
||
return refuse(path, 404, 'not_found')
|
||
}
|
||
|
||
const device = authenticate(req, url)
|
||
|
||
if (!device) {
|
||
return refuse(path, 401, 'unauthorized')
|
||
}
|
||
|
||
if (!MOBILE_LAN_WS_PATHS.includes(path)) {
|
||
return refuse(path, 403, 'forbidden')
|
||
}
|
||
|
||
const backend = await localBackend(url)
|
||
|
||
if (socket.destroyed) return
|
||
|
||
if (!backend) {
|
||
return refuse(path, 503, 'backend_unavailable')
|
||
}
|
||
|
||
if (!devices.has(device.tokenHash) || socket.destroyed) {
|
||
return refuse(path, 401, 'unauthorized')
|
||
}
|
||
|
||
url.searchParams.set('token', backend.token)
|
||
|
||
const upstream = net.connect(Number(backend.target.port) || 80, '127.0.0.1')
|
||
let closed = false
|
||
|
||
const close = () => {
|
||
if (!closed) {
|
||
closed = true
|
||
device.sockets.delete(socket)
|
||
sockets.delete(upstream)
|
||
upstream.destroy()
|
||
socket.destroy()
|
||
}
|
||
}
|
||
|
||
device.sockets.add(socket)
|
||
sockets.add(upstream)
|
||
upstream.on('error', close)
|
||
upstream.on('close', close)
|
||
socket.on('close', close)
|
||
upstream.once('connect', () => {
|
||
// No Origin / Cookie / Authorization: only what the WebSocket handshake needs.
|
||
const lines = [
|
||
`GET ${path}${url.search} HTTP/1.1`,
|
||
`Host: ${backend.target.host}`,
|
||
'Connection: Upgrade',
|
||
'Upgrade: websocket'
|
||
]
|
||
|
||
for (const key of [
|
||
'sec-websocket-key',
|
||
'sec-websocket-version',
|
||
'sec-websocket-extensions',
|
||
'sec-websocket-protocol'
|
||
]) {
|
||
const value = req.headers[key]
|
||
|
||
if (typeof value === 'string' && value) {
|
||
lines.push(`${key}: ${value}`)
|
||
}
|
||
}
|
||
|
||
log(req.method, path, 101)
|
||
upstream.write(`${lines.join('\r\n')}\r\n\r\n`)
|
||
|
||
if (head?.length) {
|
||
upstream.write(head)
|
||
}
|
||
|
||
upstream.pipe(socket)
|
||
socket.pipe(upstream)
|
||
})
|
||
}
|
||
|
||
const server = https.createServer(
|
||
{ key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' },
|
||
(req, res) => {
|
||
onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' }))
|
||
}
|
||
)
|
||
|
||
server.on('upgrade', (req, socket, head) => {
|
||
onUpgrade(req, socket, head).catch(() => socket.destroy())
|
||
})
|
||
server.on('connection', socket => {
|
||
sockets.add(socket)
|
||
socket.on('close', () => sockets.delete(socket))
|
||
})
|
||
server.on('clientError', (_error, socket) => socket.destroy())
|
||
server.on('tlsClientError', () => undefined)
|
||
|
||
return {
|
||
get port() {
|
||
return listeningPort
|
||
},
|
||
get listening() {
|
||
return listeningPort !== null
|
||
},
|
||
/** Address the socket is bound to right now, or null. */
|
||
get host() {
|
||
return listeningHost
|
||
},
|
||
listen(port: number, host: string): Promise<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: (profile: string) => 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
|
||
/** A publish got no answer at least once: a later 409 for this code is our own record. */
|
||
offerMayHaveLanded: boolean
|
||
}
|
||
|
||
export function createMobileLink(deps: MobileLinkDeps) {
|
||
const now = deps.now ?? Date.now
|
||
const lanHost = deps.listenHost ?? MOBILE_LAN_DIRECT_HOST
|
||
const loopbackHost = deps.loopbackHost ?? MOBILE_LOOPBACK_HOST
|
||
const ports = deps.ports ?? defaultPorts()
|
||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||
const listeners = new Set<(event: MobileLinkEvent) => void>()
|
||
const pcName = (deps.pcName || 'AITURK IDE').slice(0, 60)
|
||
const windows = deps.platform === 'win32'
|
||
|
||
let stored: MobileLinkStored | null = null
|
||
let identity: MobileLanIdentity | null = null
|
||
let proxy: LanProxy | null = null
|
||
let relay: RelayClient | null = null
|
||
let firewallFailure: null | 'denied' | 'failed' = null
|
||
/** Last answer of the real rule query. */
|
||
let rule: FirewallRuleInfo = { state: 'unknown' }
|
||
let session: null | PairSession = null
|
||
let queue: Promise<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)
|
||
}
|
||
}
|
||
}
|
||
|
||
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||
|
||
/**
|
||
* Publishes (or publishes again) the rendezvous record of `current` in the
|
||
* background; the code path stays `pending` until every try is over. The
|
||
* code and the QR are never touched here.
|
||
*/
|
||
function publishOffer(current: PairSession, relayId: null | string) {
|
||
current.codePath = 'pending'
|
||
|
||
const input = {
|
||
code: current.code,
|
||
addresses: current.addresses,
|
||
port: current.port,
|
||
fingerprint: current.fingerprint,
|
||
relay: relayId
|
||
}
|
||
|
||
void publishPairOffer(deps.fetch, input, {
|
||
sleep,
|
||
cancelled: () => session !== current,
|
||
log: line => deps.log?.(`pair ${line}`),
|
||
earlierTryMayHaveLanded: current.offerMayHaveLanded,
|
||
onMayHaveLanded: () => {
|
||
current.offerMayHaveLanded = true
|
||
}
|
||
}).then(offer => {
|
||
if (session !== current) {
|
||
// Pairing ended meanwhile; do not leave the record behind.
|
||
if (offer) {
|
||
void cancelPairOffer(deps.fetch, offer)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
current.offer = offer
|
||
current.codePath = offer ? 'ready' : 'unavailable'
|
||
emit()
|
||
})
|
||
}
|
||
|
||
/** The relay registration changed while a QR is on screen: show the current address. */
|
||
function refreshSessionQr(data: MobileLinkStored) {
|
||
if (!session) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
session.qr = buildPairQrPayload({
|
||
addresses: session.addresses,
|
||
port: session.port,
|
||
fingerprint: session.fingerprint,
|
||
code: session.code,
|
||
relay: data.relayId
|
||
})
|
||
} catch {
|
||
// neither a LAN address nor a relay left; the old payload stays until pairing ends
|
||
}
|
||
}
|
||
|
||
async function ensureProxy(data: MobileLinkStored): Promise<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 profile => {
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
return deps.resolveBackend(profile)
|
||
},
|
||
version: deps.version,
|
||
pcName,
|
||
devices: data.devices,
|
||
now,
|
||
randomBytes: deps.randomBytes,
|
||
randomUUID: deps.randomUUID,
|
||
log: deps.log,
|
||
onDevicesChanged: async devices => {
|
||
data.devices = devices
|
||
await deps.store.save(data)
|
||
},
|
||
onPairingEnded: (reason, device) => {
|
||
// The gateway record is useless once the code is spent, locked or expired.
|
||
dropSession(true)
|
||
|
||
if (reason === 'claimed') {
|
||
// This phone was paired against the current relay address.
|
||
data.relayReissued = false
|
||
}
|
||
|
||
void serial(() => syncListening(data)).then(
|
||
() => emit(reason === 'claimed' && device ? { paired: device.name } : undefined),
|
||
() => emit()
|
||
)
|
||
}
|
||
})
|
||
|
||
return proxy
|
||
}
|
||
|
||
/**
|
||
* The tunnel client. It only copies bytes between the gateway and a plain TCP
|
||
* connection to the proxy; TLS stays between the phone and the proxy. null
|
||
* when no header-capable WebSocket client was injected.
|
||
*/
|
||
function ensureRelay(data: MobileLinkStored): null | RelayClient {
|
||
if (relay || (!deps.WebSocket && !deps.relayConnect)) {
|
||
return relay
|
||
}
|
||
|
||
const WebSocketCtor = deps.WebSocket
|
||
|
||
relay = createRelayClient({
|
||
connect:
|
||
deps.relayConnect ??
|
||
((url, headers) =>
|
||
new (WebSocketCtor as RelayWebSocketCtor)(url, {
|
||
headers,
|
||
handshakeTimeout: 20_000,
|
||
perMessageDeflate: false,
|
||
followRedirects: false,
|
||
maxPayload: RELAY_MAX_FRAME_BYTES
|
||
})),
|
||
fetch: deps.fetch,
|
||
dial: () => {
|
||
const port = proxy?.port ?? null
|
||
const host = proxy?.host ?? null
|
||
|
||
if (port === null || host === null) {
|
||
return null
|
||
}
|
||
|
||
// A socket bound to every interface is reached on loopback; anything else on its own address.
|
||
return net.connect({ port, host: host === MOBILE_LAN_DIRECT_HOST ? MOBILE_LOOPBACK_HOST : host, noDelay: true })
|
||
},
|
||
credentials: {
|
||
// relayId on disk, hostToken only in the vault; both must describe the same registration.
|
||
async load() {
|
||
if (!data.relayId) {
|
||
return null
|
||
}
|
||
|
||
const row = (await deps.vault.load())?.relay
|
||
|
||
return isRelayCredentials(row) && row.relayId === data.relayId
|
||
? { relayId: row.relayId, hostToken: row.hostToken }
|
||
: null
|
||
},
|
||
async save(credentials, info) {
|
||
const previous = data.relayId
|
||
|
||
await deps.vault.save({
|
||
...((await deps.vault.load()) ?? {}),
|
||
relay: { relayId: credentials.relayId, hostToken: credentials.hostToken }
|
||
})
|
||
data.relayId = credentials.relayId
|
||
|
||
// The public address the paired phones stored is gone with the old registration.
|
||
if ((info.reissued || (previous !== null && previous !== credentials.relayId)) && data.devices.length > 0) {
|
||
data.relayReissued = true
|
||
}
|
||
|
||
await persist()
|
||
refreshSessionQr(data)
|
||
emit()
|
||
},
|
||
async clear() {
|
||
if (data.relayId !== null && data.devices.length > 0) {
|
||
data.relayReissued = true
|
||
}
|
||
|
||
data.relayId = null
|
||
await persist()
|
||
}
|
||
},
|
||
setTimer,
|
||
clearTimer,
|
||
log: deps.log,
|
||
onState: () => emit()
|
||
})
|
||
|
||
return relay
|
||
}
|
||
|
||
async function ensurePort(data: MobileLinkStored): Promise<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,
|
||
offerMayHaveLanded: false
|
||
}
|
||
|
||
session = current
|
||
ensureRelay(data)?.start()
|
||
|
||
// Not awaited: the QR must not wait for the gateway.
|
||
publishOffer(current, relayUrl)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/**
|
||
* "Yeniden dene" next to "Kodla bağlanma şu an kullanılamıyor": publishes
|
||
* the rendezvous record of the RUNNING pairing again. Same code, same QR,
|
||
* same expiry; nothing happens unless the code path is `unavailable`.
|
||
*/
|
||
pairRepublish: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (session?.codePath === 'unavailable' && proxy?.pairingOpen() && session) {
|
||
publishOffer(session, data.relayId)
|
||
}
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
pairCancel: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
endPairingQuietly()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
devices: () => serial(async () => snapshot(await load()).devices),
|
||
|
||
deviceRevoke: (id: unknown) =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (typeof id !== 'string' || !UUID_RE.test(id)) {
|
||
throw new MobileLinkError('invalid_request')
|
||
}
|
||
|
||
if (proxy) {
|
||
await proxy.revoke(id)
|
||
} else {
|
||
data.devices = data.devices.filter(device => device.id !== id)
|
||
await persist()
|
||
}
|
||
|
||
if (data.devices.length === 0 && data.relayReissued) {
|
||
data.relayReissued = false
|
||
await persist()
|
||
}
|
||
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/**
|
||
* "Aynı Wi-Fi'da doğrudan bağlantıya da izin ver": the ONLY place that adds
|
||
* the Windows Firewall rule (one UAC prompt, asked because the user pressed
|
||
* the button). Direct access is switched on only when the rule is in place.
|
||
*/
|
||
lanAllow: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (!data.enabled) {
|
||
throw new MobileLinkError('not_enabled')
|
||
}
|
||
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
await ensureProxy(data)
|
||
await ensurePort(data)
|
||
await refreshFirewall()
|
||
|
||
if (windows && lanFirewall(data) !== 'allowed') {
|
||
const result = await deps.firewall.addRule(data.port as number)
|
||
|
||
if (!result.ok) {
|
||
firewallFailure = result.reason ?? 'failed'
|
||
await persist()
|
||
|
||
return snapshot(data)
|
||
}
|
||
|
||
data.firewallPort = data.port
|
||
await refreshFirewall()
|
||
}
|
||
|
||
firewallFailure = null
|
||
data.lanDirect = true
|
||
// A QR on screen was built without LAN addresses.
|
||
endPairingQuietly()
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/**
|
||
* "Röle kimliğini yenile": new relay registration, every paired phone is
|
||
* dropped (its stored address and, if the token leaked, its path are dead)
|
||
* and the window tells the user to pair again.
|
||
*/
|
||
relayRenew: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (!data.enabled) {
|
||
throw new MobileLinkError('not_enabled')
|
||
}
|
||
|
||
if (deps.isRemoteMode()) {
|
||
throw new MobileLinkError('remote_mode')
|
||
}
|
||
|
||
const instance = await ensureProxy(data)
|
||
const client = ensureRelay(data)
|
||
|
||
endPairingQuietly()
|
||
|
||
if (!client || !(await client.renew())) {
|
||
throw new MobileLinkError('relay_unavailable')
|
||
}
|
||
|
||
const hadPhones = data.devices.length > 0
|
||
|
||
for (const device of instance.devices()) {
|
||
await instance.revoke(device.id)
|
||
}
|
||
|
||
data.relayReissued = hadPhones
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** Back to loopback only. The firewall rule is left alone (no UAC). */
|
||
lanDisallow: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
data.lanDirect = false
|
||
firewallFailure = null
|
||
endPairingQuietly()
|
||
await persist()
|
||
await syncListening(data)
|
||
|
||
return snapshot(data)
|
||
}),
|
||
|
||
/** Removes the rule (one UAC prompt) and, with it, direct LAN access. */
|
||
firewallRemove: () =>
|
||
serial(async () => {
|
||
const data = await load()
|
||
|
||
if (windows) {
|
||
const result = await deps.firewall.removeRule()
|
||
|
||
if (result.ok) {
|
||
data.firewallPort = null
|
||
data.lanDirect = false
|
||
firewallFailure = null
|
||
endPairingQuietly()
|
||
await persist()
|
||
await refreshFirewall()
|
||
await syncListening(data)
|
||
} else {
|
||
firewallFailure = result.reason ?? 'failed'
|
||
}
|
||
}
|
||
|
||
return snapshot(data)
|
||
})
|
||
}
|
||
|
||
// Kept for callers written against beta.15.
|
||
return { ...api, firewallAllow: api.lanAllow, firewallRetry: api.lanAllow }
|
||
}
|
||
|
||
export type MobileLink = ReturnType<typeof createMobileLink>
|