feat(aiturk): phone link works from any network through an end-to-end encrypted tunnel (1.0.0-beta.16)
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
This commit is contained in:
1 parent
03d3803dfc
commit
2ed9a6b2de
11 files changed
+2905
-130
No files matched your search
@@ -6,9 +6,17 @@
|
||||
* on 127.0.0.1 only; this module adds a small TLS reverse proxy in the Electron
|
||||
* main process plus the pairing session that hands a phone its own token.
|
||||
*
|
||||
* Contract: workspace/aiturk-mobil-eslestirme-spec.md (v1). The Android app and
|
||||
* the gateway rendezvous endpoints are written against the same document, so
|
||||
* paths, field names and error codes here must not drift.
|
||||
* 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
|
||||
@@ -36,6 +44,17 @@ 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
|
||||
@@ -48,6 +67,10 @@ 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}$/
|
||||
@@ -186,16 +209,33 @@ export function defaultRouteAddress(): Promise<null | string> {
|
||||
|
||||
// ── QR payload ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** `aiturk://pair?v=1&u=<ip:port>[&u=…]&fp=<43>&c=<code>` (contract §2). */
|
||||
export function buildPairQrPayload(input: { addresses: string[]; port: number; fingerprint: string; code: string }): string {
|
||||
/**
|
||||
* `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 (!hosts.length || !Number.isInteger(input.port) || input.port < 1 || input.port > 65535) {
|
||||
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) {
|
||||
throw new MobileLinkError('no_lan_address')
|
||||
}
|
||||
|
||||
// ip:port, the fingerprint (base64url) and the code need no escaping in a query.
|
||||
return `aiturk://pair?v=1&${hosts.map(host => `u=${host}:${input.port}`).join('&')}&fp=${input.fingerprint}&c=${input.code}`
|
||||
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) ─
|
||||
@@ -228,21 +268,24 @@ function pairFetchInit(body: unknown, headers: Record<string, string> = {}) {
|
||||
*/
|
||||
export async function publishPairOffer(
|
||||
fetcher: PairFetch,
|
||||
input: { code: string; addresses: string[]; port: number; fingerprint: string }
|
||||
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}`)
|
||||
|
||||
if (!urls.length) {
|
||||
// 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 })
|
||||
pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) })
|
||||
)
|
||||
|
||||
if (response.status !== 201) {
|
||||
@@ -285,11 +328,83 @@ export interface FirewallResult {
|
||||
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 {
|
||||
@@ -335,6 +450,8 @@ 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 => {
|
||||
@@ -366,7 +483,24 @@ export function createWindowsFirewall(deps: {
|
||||
}
|
||||
})
|
||||
|
||||
return { addRule: port => run('add', port), removeRule: () => run('remove', MOBILE_LAN_DEFAULT_PORT) }
|
||||
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 ──────────────────────────────────────────────
|
||||
@@ -514,6 +648,7 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
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 })
|
||||
@@ -929,6 +1064,10 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
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)
|
||||
@@ -938,6 +1077,7 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
server.removeListener('error', onError)
|
||||
server.on('error', () => undefined)
|
||||
listeningPort = (server.address() as net.AddressInfo).port
|
||||
listeningHost = host
|
||||
resolve(listeningPort)
|
||||
})
|
||||
})
|
||||
@@ -951,6 +1091,7 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
}
|
||||
|
||||
listeningPort = null
|
||||
listeningHost = null
|
||||
|
||||
for (const socket of sockets) {
|
||||
socket.destroy()
|
||||
@@ -1014,6 +1155,16 @@ export interface MobileLinkStored {
|
||||
/** 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 {
|
||||
@@ -1078,7 +1229,10 @@ export function parseMobileLinkStored(raw: unknown): MobileLinkStored {
|
||||
port: validPort(row.port) ? row.port : null,
|
||||
firewallPort: validPort(row.firewallPort) ? row.firewallPort : null,
|
||||
fingerprint: typeof row.fingerprint === 'string' ? row.fingerprint : null,
|
||||
devices
|
||||
devices,
|
||||
lanDirect: row.lanDirect === true,
|
||||
relayId: typeof row.relayId === 'string' && UUID_RE.test(row.relayId) ? row.relayId.toLowerCase() : null,
|
||||
relayReissued: row.relayReissued === true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,6 +1243,7 @@ export type MobileLinkErrorCode =
|
||||
| 'vault_unavailable'
|
||||
| 'not_enabled'
|
||||
| 'no_lan_address'
|
||||
| 'relay_unavailable'
|
||||
| 'port_unavailable'
|
||||
| 'invalid_port'
|
||||
| 'invalid_request'
|
||||
@@ -1106,14 +1261,37 @@ export class MobileLinkError extends Error {
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -1122,6 +1300,12 @@ export interface MobileLinkState {
|
||||
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[]
|
||||
}
|
||||
@@ -1140,9 +1324,20 @@ export interface MobileLinkDeps {
|
||||
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
|
||||
@@ -1175,20 +1370,37 @@ function probeFreePort(port: number, host: string): Promise<null | number> {
|
||||
})
|
||||
}
|
||||
|
||||
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 listenHost = deps.listenHost ?? '0.0.0.0'
|
||||
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
|
||||
let session: null | { code: string; qr: string; expiresAt: number; codePath: 'pending' | 'ready' | 'unavailable'; offer: null | PairOffer; timer: unknown } = 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
|
||||
|
||||
@@ -1215,27 +1427,68 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 (deps.platform !== 'win32' || data.port === null) {
|
||||
if (!windows) {
|
||||
return 'not-needed'
|
||||
}
|
||||
|
||||
if (data.firewallPort === data.port) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
return firewallFailure ?? '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: Boolean(proxy?.listening),
|
||||
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 } : null,
|
||||
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 }))
|
||||
@@ -1273,6 +1526,25 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1318,6 +1590,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
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()
|
||||
@@ -1328,13 +1606,92 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
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, listenHost)
|
||||
const free = await probeFreePort(candidate, bindHost(data))
|
||||
|
||||
if (free !== null) {
|
||||
data.port = free
|
||||
@@ -1347,15 +1704,22 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
|
||||
async function startListening(data: MobileLinkStored, instance: LanProxy) {
|
||||
const host = bindHost(data)
|
||||
|
||||
if (instance.listening) {
|
||||
return
|
||||
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, listenHost)
|
||||
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.
|
||||
@@ -1372,7 +1736,10 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
throw new MobileLinkError('port_unavailable')
|
||||
}
|
||||
|
||||
/** Listen only while enabled AND (a phone is paired OR pairing is in progress). */
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -1380,29 +1747,20 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
const instance = wanted() ? await ensureProxy(data) : proxy
|
||||
|
||||
if (!wanted()) {
|
||||
relay?.stop()
|
||||
await instance?.stop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startListening(data, instance as LanProxy)
|
||||
ensureRelay(data)?.start()
|
||||
}
|
||||
|
||||
async function addFirewallRule(data: MobileLinkStored) {
|
||||
if (deps.platform !== 'win32' || data.port === null || data.firewallPort === data.port) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await deps.firewall.addRule(data.port)
|
||||
|
||||
if (result.ok) {
|
||||
firewallFailure = null
|
||||
data.firewallPort = data.port
|
||||
} else {
|
||||
firewallFailure = result.reason ?? 'failed'
|
||||
}
|
||||
|
||||
await persist()
|
||||
/** Cancels a pairing whose QR would no longer describe how the phone can connect. */
|
||||
function endPairingQuietly() {
|
||||
proxy?.cancelPairing()
|
||||
dropSession(true)
|
||||
}
|
||||
|
||||
const api = {
|
||||
@@ -1412,9 +1770,21 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
|
||||
state: () => serial(async () => snapshot(await load())),
|
||||
state: () =>
|
||||
serial(async () => {
|
||||
const data = await load()
|
||||
|
||||
/** App start: resume listening when the feature is on and a phone is paired. No UAC, no pairing. */
|
||||
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()
|
||||
@@ -1422,6 +1792,7 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
stopped = false
|
||||
|
||||
if (data.enabled && data.devices.length > 0 && !deps.isRemoteMode()) {
|
||||
await refreshFirewall()
|
||||
await syncListening(data)
|
||||
}
|
||||
|
||||
@@ -1434,9 +1805,11 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
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()
|
||||
@@ -1447,24 +1820,21 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
|
||||
stopped = false
|
||||
await ensureProxy(data)
|
||||
await refreshFirewall()
|
||||
await ensurePort(data)
|
||||
data.enabled = true
|
||||
await persist()
|
||||
// First time on this port: one UAC prompt. A refusal does not undo "enabled";
|
||||
// the window shows a warning and "Yeniden dene".
|
||||
await addFirewallRule(data)
|
||||
await syncListening(data)
|
||||
|
||||
return snapshot(data)
|
||||
}),
|
||||
|
||||
/** Stops listening. The firewall rule and the paired phones are kept. */
|
||||
/** Stops listening and the relay. The firewall rule and the paired phones are kept. */
|
||||
disable: () =>
|
||||
serial(async () => {
|
||||
const data = await load()
|
||||
|
||||
proxy?.cancelPairing()
|
||||
dropSession(true)
|
||||
endPairingQuietly()
|
||||
data.enabled = false
|
||||
await persist()
|
||||
await syncListening(data)
|
||||
@@ -1485,9 +1855,21 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
|
||||
const instance = await ensureProxy(data)
|
||||
const addresses = (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS)
|
||||
|
||||
if (!addresses.length) {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -1498,22 +1880,26 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
const fingerprint = (identity as MobileLanIdentity).fingerprint
|
||||
const { code, expiresAt } = instance.beginPairing()
|
||||
|
||||
const current = {
|
||||
const current: PairSession = {
|
||||
code,
|
||||
qr: buildPairQrPayload({ addresses, port, fingerprint, code }),
|
||||
qr: buildPairQrPayload({ addresses, port, fingerprint, code, relay: relayUrl }),
|
||||
expiresAt,
|
||||
codePath: 'pending' as 'pending' | 'ready' | 'unavailable',
|
||||
offer: null as null | PairOffer,
|
||||
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)
|
||||
}, 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 }).then(offer => {
|
||||
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) {
|
||||
@@ -1535,8 +1921,7 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
serial(async () => {
|
||||
const data = await load()
|
||||
|
||||
proxy?.cancelPairing()
|
||||
dropSession(true)
|
||||
endPairingQuietly()
|
||||
await syncListening(data)
|
||||
|
||||
return snapshot(data)
|
||||
@@ -1559,12 +1944,22 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
await persist()
|
||||
}
|
||||
|
||||
if (data.devices.length === 0 && data.relayReissued) {
|
||||
data.relayReissued = false
|
||||
await persist()
|
||||
}
|
||||
|
||||
await syncListening(data)
|
||||
|
||||
return snapshot(data)
|
||||
}),
|
||||
|
||||
firewallRetry: () =>
|
||||
/**
|
||||
* "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()
|
||||
|
||||
@@ -1572,22 +1967,107 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
throw new MobileLinkError('not_enabled')
|
||||
}
|
||||
|
||||
await addFirewallRule(data)
|
||||
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 (deps.platform === 'win32') {
|
||||
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'
|
||||
}
|
||||
@@ -1597,7 +2077,8 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
return api
|
||||
// Kept for callers written against beta.15.
|
||||
return { ...api, firewallAllow: api.lanAllow, firewallRetry: api.lanAllow }
|
||||
}
|
||||
|
||||
export type MobileLink = ReturnType<typeof createMobileLink>
|
||||
Reference in new issue
Block a user