fix(mobile): route phone APIs to selected profile and unblock runtime readiness
This commit is contained in:
1 parent
b1db2870c1
commit
61d765e2df
15 files changed
+970
-176
No files matched your search
@@ -150,7 +150,11 @@ export function selectLanAddresses(
|
||||
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) })
|
||||
rows.push({
|
||||
address: item.address,
|
||||
primary: item.address === defaultRoute,
|
||||
virtual: VIRTUAL_ADAPTER_RE.test(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +220,13 @@ export function defaultRouteAddress(): Promise<null | string> {
|
||||
* 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 {
|
||||
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
|
||||
|
||||
@@ -437,7 +447,10 @@ export interface MobileFirewall {
|
||||
|
||||
/** `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'
|
||||
const root =
|
||||
systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot)
|
||||
? systemRoot.replace(/\\+$/, '')
|
||||
: 'C:\\Windows'
|
||||
|
||||
return {
|
||||
file: `${root}\\System32\\netsh.exe`,
|
||||
@@ -479,7 +492,9 @@ export function parseFirewallRuleOutput(stdout: string, exitCode: null | number)
|
||||
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)))
|
||||
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()))
|
||||
@@ -519,7 +534,10 @@ function assertPort(port: number): number {
|
||||
* 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 root =
|
||||
systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot)
|
||||
? systemRoot.replace(/\\+$/, '')
|
||||
: 'C:\\Windows'
|
||||
|
||||
const netshArgs =
|
||||
action === 'add'
|
||||
@@ -537,7 +555,11 @@ export function firewallCommand(action: 'add' | 'remove', port: number, systemRo
|
||||
}
|
||||
|
||||
export interface FirewallSpawn {
|
||||
(file: string, args: string[], options: Record<string, unknown>): {
|
||||
(
|
||||
file: string,
|
||||
args: string[],
|
||||
options: Record<string, unknown>
|
||||
): {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
}
|
||||
}
|
||||
@@ -686,7 +708,9 @@ export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
|
||||
export interface LanProxyOptions {
|
||||
tls: { key: string; cert: string }
|
||||
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
|
||||
resolveBackend: () => Promise<MobileBackend>
|
||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||
/** Resolution only, never the duration of an agent/tool/cron execution. */
|
||||
backendResolveTimeoutMs?: number
|
||||
version: string
|
||||
pcName: string
|
||||
devices?: MobileDeviceRecord[]
|
||||
@@ -733,7 +757,13 @@ const DROPPED_REQUEST_HEADERS = new Set([
|
||||
|
||||
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' }
|
||||
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
|
||||
@@ -752,7 +782,13 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
}
|
||||
|
||||
const records = (): MobileDeviceRecord[] =>
|
||||
[...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ id, name, tokenHash, createdAt, lastSeenAt }))
|
||||
[...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) {
|
||||
@@ -814,9 +850,21 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return device
|
||||
}
|
||||
|
||||
async function localBackend(): Promise<null | { target: URL; token: string }> {
|
||||
async function localBackend(url: URL): Promise<null | { target: URL; token: string }> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const backend = await options.resolveBackend()
|
||||
// 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.
|
||||
@@ -827,6 +875,8 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return { target, token: backend.token }
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +933,13 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
}
|
||||
|
||||
// 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() : ''
|
||||
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' })
|
||||
@@ -933,12 +989,27 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return reply(req, res, path, 500, { error: 'internal' })
|
||||
}
|
||||
|
||||
options.onPairingEnded?.('claimed', records().find(row => row.id === device.id))
|
||||
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 })
|
||||
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 }) {
|
||||
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)) {
|
||||
@@ -1037,7 +1108,9 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return reply(req, res, path, 403, { error: 'forbidden' })
|
||||
}
|
||||
|
||||
const backend = await localBackend()
|
||||
const backend = await localBackend(url)
|
||||
|
||||
if (res.destroyed) return
|
||||
|
||||
if (!backend) {
|
||||
return reply(req, res, path, 503, { error: 'backend_unavailable' })
|
||||
@@ -1086,7 +1159,9 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return refuse(path, 403, 'forbidden')
|
||||
}
|
||||
|
||||
const backend = await localBackend()
|
||||
const backend = await localBackend(url)
|
||||
|
||||
if (socket.destroyed) return
|
||||
|
||||
if (!backend) {
|
||||
return refuse(path, 503, 'backend_unavailable')
|
||||
@@ -1118,9 +1193,19 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
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']
|
||||
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']) {
|
||||
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) {
|
||||
@@ -1140,9 +1225,12 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
})
|
||||
}
|
||||
|
||||
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' }))
|
||||
})
|
||||
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())
|
||||
@@ -1201,7 +1289,11 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
beginPairing(): { code: string; expiresAt: number } {
|
||||
const code = generatePairCode(randomBytes)
|
||||
|
||||
pairing = { codeHash: crypto.createHash('sha256').update(code).digest(), expiresAt: now() + codeTtlMs, attempts: 0 }
|
||||
pairing = {
|
||||
codeHash: crypto.createHash('sha256').update(code).digest(),
|
||||
expiresAt: now() + codeTtlMs,
|
||||
attempts: 0
|
||||
}
|
||||
|
||||
return { code, expiresAt: pairing.expiresAt }
|
||||
},
|
||||
@@ -1217,7 +1309,13 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
pairingOpen,
|
||||
records,
|
||||
devices: (): MobileDeviceView[] =>
|
||||
[...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ id, name, createdAt, lastSeenAt, live: live.size })),
|
||||
[...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) {
|
||||
@@ -1293,7 +1391,8 @@ export function createMobileLinkFileStore(directory: string, file: string, fs: M
|
||||
}
|
||||
}
|
||||
|
||||
const validPort = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536
|
||||
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>
|
||||
@@ -1407,12 +1506,13 @@ export interface MobileLinkState {
|
||||
devices: MobileDeviceView[]
|
||||
}
|
||||
|
||||
export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
||||
export type MobileLinkEvent =
|
||||
{ type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
||||
|
||||
export interface MobileLinkDeps {
|
||||
vault: MobileLanVault
|
||||
store: MobileLinkStore
|
||||
resolveBackend: () => Promise<MobileBackend>
|
||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||
isRemoteMode: () => boolean
|
||||
firewall: MobileFirewall
|
||||
fetch: PairFetch
|
||||
@@ -1577,7 +1677,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
port: data.port,
|
||||
remote: deps.isRemoteMode(),
|
||||
firewall: firewallState(data),
|
||||
relay: { status: relayState.status, code: relayState.code, ready: data.relayId !== null, reissued: data.relayReissued },
|
||||
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,
|
||||
@@ -1586,7 +1691,13 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
},
|
||||
pcName,
|
||||
pairing: session
|
||||
? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath, securityCode: session.fingerprint.slice(0, 8) }
|
||||
? {
|
||||
code: session.code,
|
||||
qr: session.qr,
|
||||
expiresAt: session.expiresAt,
|
||||
codePath: session.codePath,
|
||||
securityCode: session.fingerprint.slice(0, 8)
|
||||
}
|
||||
: null,
|
||||
devices: proxy
|
||||
? proxy.devices()
|
||||
@@ -1635,7 +1746,13 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
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 }
|
||||
const input = {
|
||||
code: current.code,
|
||||
addresses: current.addresses,
|
||||
port: current.port,
|
||||
fingerprint: current.fingerprint,
|
||||
relay: relayId
|
||||
}
|
||||
|
||||
void publishPairOffer(deps.fetch, input, {
|
||||
sleep,
|
||||
@@ -1704,12 +1821,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
|
||||
proxy = createLanProxy({
|
||||
tls: identity,
|
||||
resolveBackend: async () => {
|
||||
resolveBackend: async profile => {
|
||||
if (deps.isRemoteMode()) {
|
||||
throw new MobileLinkError('remote_mode')
|
||||
}
|
||||
|
||||
return deps.resolveBackend()
|
||||
return deps.resolveBackend(profile)
|
||||
},
|
||||
version: deps.version,
|
||||
pcName,
|
||||
@@ -1785,12 +1902,17 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
|
||||
const row = (await deps.vault.load())?.relay
|
||||
|
||||
return isRelayCredentials(row) && row.relayId === data.relayId ? { relayId: row.relayId, hostToken: row.hostToken } : null
|
||||
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 } })
|
||||
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.
|
||||
@@ -1876,7 +1998,8 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
* 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)
|
||||
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
|
||||
@@ -2002,7 +2125,9 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
|
||||
// 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) : []
|
||||
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')
|
||||
@@ -2022,9 +2147,12 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
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),
|
||||
timer: setTimer(
|
||||
() => {
|
||||
instance.pairingOpen()
|
||||
},
|
||||
Math.max(0, expiresAt - now()) + 50
|
||||
),
|
||||
addresses,
|
||||
port,
|
||||
fingerprint,
|
||||
|
||||
Reference in new issue
Block a user