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
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
shell,
|
||||
systemPreferences
|
||||
} from 'electron'
|
||||
import { WebSocket as RelayWebSocket } from 'ws'
|
||||
|
||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||
@@ -17866,16 +17867,33 @@ function getMobileLink(): MobileLink {
|
||||
return { baseUrl: connection.baseUrl, token: connection.token }
|
||||
},
|
||||
isRemoteMode: mobileLinkRemoteMode,
|
||||
firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }),
|
||||
firewall: createWindowsFirewall({
|
||||
spawn,
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||
// console code page; latin1 keeps every byte and the parser needs no translated word.
|
||||
run: (file, args) =>
|
||||
new Promise(resolve => {
|
||||
execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => {
|
||||
const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0
|
||||
|
||||
resolve({ code, stdout: String(stdout ?? '') })
|
||||
})
|
||||
})
|
||||
}),
|
||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||
WebSocket: RelayWebSocket,
|
||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||
version: app.getVersion(),
|
||||
pcName: os.hostname(),
|
||||
platform: process.platform,
|
||||
// Method + path + status only (no token, code, query or body). Successful
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis.
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
if (/^relay /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
rememberLog(`[mobile-lan] ${line}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,56 +6,72 @@ import { drawQr } from './mobile/qr.mjs'
|
||||
const TEXT = {
|
||||
tr: {
|
||||
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||
intro: 'AITURK Mobil uygulaması, aynı Wi-Fi ağındayken bu bilgisayardaki IDE konuşmalarınızı görür ve sohbet eder. Hesap girişi gerekmez.',
|
||||
enabled: 'Telefon bağlantısı açık', disabled: 'Telefon bağlantısı kapalı',
|
||||
intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
|
||||
toggle: 'Telefon bağlantısını kullan',
|
||||
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||
firewallNeeded: 'Windows Güvenlik Duvarı izni henüz verilmedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
||||
firewallDenied: 'Windows izin penceresi reddedildi; Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
||||
firewallFailed: 'Windows Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
||||
retry: 'Yeniden dene', addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
|
||||
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.',
|
||||
sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||
footer: port => `Bağlantı noktası: ${port}`, removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||
errors: {
|
||||
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor.',
|
||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
|
||||
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||
intro: 'The AITURK Mobile app can see and continue this computer’s IDE conversations while on the same Wi-Fi network. No account sign-in is needed.',
|
||||
enabled: 'Phone connection is on', disabled: 'Phone connection is off',
|
||||
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
|
||||
toggle: 'Use phone connection',
|
||||
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||
firewallNeeded: 'Windows Firewall permission has not been granted yet. Without it the phone may not reach this computer.',
|
||||
firewallDenied: 'The Windows permission prompt was declined, so the Firewall rule was not added. Without it the phone may not reach this computer.',
|
||||
firewallFailed: 'The Windows Firewall rule could not be added. Without it the phone may not reach this computer.',
|
||||
retry: 'Try again', addPhone: 'Add a phone', cancel: 'Cancel',
|
||||
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
|
||||
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
|
||||
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
|
||||
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
|
||||
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
|
||||
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
|
||||
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
|
||||
addPhone: 'Add a phone', cancel: 'Cancel',
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.',
|
||||
sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||
footer: port => `Port: ${port}`, removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
|
||||
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
|
||||
errors: {
|
||||
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||
not_enabled: 'Turn phone connection on first.',
|
||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable).',
|
||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
|
||||
relay_unavailable: 'The internet cannot be reached, try again.',
|
||||
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||
failed: 'That did not work. Try again.',
|
||||
},
|
||||
@@ -66,7 +82,8 @@ const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementByI
|
||||
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||
['t-or-code', 'orCode'], ['t-same-wifi', 'sameWifi'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['firewall-retry', 'retry'],
|
||||
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
@@ -83,10 +100,20 @@ function tick() {
|
||||
|
||||
function render(state) {
|
||||
current = state
|
||||
$('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled
|
||||
$('enabled').checked = state.enabled
|
||||
$('remote-box').hidden = !state.remote
|
||||
const firewall = { needed: t.firewallNeeded, denied: t.firewallDenied, failed: t.firewallFailed }[state.firewall]
|
||||
$('firewall-box').hidden = !(state.enabled && firewall); $('firewall-text').textContent = firewall || ''
|
||||
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
|
||||
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
|
||||
$('status-box').hidden = !state.enabled || state.remote
|
||||
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
|
||||
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
|
||||
$('reissued-box').hidden = !(state.enabled && relay.reissued)
|
||||
const noRule = lan.direct && lan.firewall === 'missing'
|
||||
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
|
||||
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
|
||||
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
|
||||
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
|
||||
$('lan-disallow').hidden = !lan.direct
|
||||
$('pair-section').hidden = !state.enabled || state.remote
|
||||
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||
if (state.pairing) {
|
||||
@@ -94,6 +121,8 @@ function render(state) {
|
||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||
tick()
|
||||
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||
@@ -109,9 +138,9 @@ function render(state) {
|
||||
label.append(name, added, seen); row.append(label, button)
|
||||
return row
|
||||
}))
|
||||
$('footer').hidden = state.port === null
|
||||
$('footer-info').textContent = state.port === null ? '' : t.footer(state.port)
|
||||
$('firewall-remove').hidden = state.firewall !== 'ok'
|
||||
$('footer').hidden = !state.enabled || state.remote
|
||||
$('relay-renew').hidden = !relay.ready
|
||||
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
|
||||
}
|
||||
|
||||
async function run(work) {
|
||||
@@ -126,7 +155,9 @@ async function refresh() { try { render(await host.call('state')) } catch (error
|
||||
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||
$('firewall-retry').onclick = () => run(async () => { notice(''); render(await host.call('firewall.retry')) })
|
||||
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||
setInterval(tick, 1000)
|
||||
|
||||
@@ -14,3 +14,13 @@
|
||||
.device button { flex:none; margin:0 }
|
||||
footer { margin-top:34px }
|
||||
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
||||
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
|
||||
.status-row strong { color:var(--text); overflow-wrap:anywhere }
|
||||
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
|
||||
.status-row strong[data-state=connected]::before { background:var(--accent) }
|
||||
.status-row strong[data-state=failed]::before { background:var(--danger) }
|
||||
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
|
||||
.status-actions button { margin:0; min-height:44px }
|
||||
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||
footer button.link { min-height:44px }
|
||||
@@ -6,7 +6,12 @@
|
||||
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||
<section id="firewall-box" class="warning" hidden><p id="firewall-text"></p><button id="firewall-retry" class="secondary"></button></section>
|
||||
<section id="status-box" hidden>
|
||||
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||
</section>
|
||||
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
|
||||
<section id="pair-section" hidden>
|
||||
<button id="pair-begin"></button>
|
||||
<div id="pair-live" hidden>
|
||||
@@ -16,6 +21,7 @@
|
||||
<p id="pair-code" class="code" aria-live="off"></p>
|
||||
<p id="pair-code-path"></p>
|
||||
<p id="pair-countdown" class="countdown"></p>
|
||||
<p id="pair-security" class="security"></p>
|
||||
<p id="t-same-wifi" class="hint"></p>
|
||||
<button id="pair-cancel" class="secondary"></button>
|
||||
</div>
|
||||
@@ -23,5 +29,5 @@
|
||||
<section id="devices-section" hidden>
|
||||
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
||||
</section>
|
||||
<footer id="footer" hidden><small id="footer-info"></small> <button id="firewall-remove" class="link"></button></footer>
|
||||
<footer id="footer" hidden><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
|
||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
@@ -22,7 +22,12 @@ export function createMobileWindow({ assets, link, show = true }) {
|
||||
'pair.cancel': () => link.pairCancel(),
|
||||
devices: () => link.devices(),
|
||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||
'firewall.retry': () => link.firewallRetry(),
|
||||
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||
'lan.allow': () => link.lanAllow(),
|
||||
'lan.disallow': () => link.lanDisallow(),
|
||||
'firewall.allow': () => link.lanAllow(),
|
||||
'firewall.retry': () => link.lanAllow(),
|
||||
'relay.renew': () => link.relayRenew(),
|
||||
'firewall.remove': () => link.firewallRemove(),
|
||||
}
|
||||
const handler = async (event, action, params) => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import net from 'node:net'
|
||||
@@ -22,16 +23,21 @@ import {
|
||||
createMobileLink,
|
||||
createWindowsFirewall,
|
||||
firewallCommand,
|
||||
firewallQueryCommand,
|
||||
formatPairCode,
|
||||
generatePairCode,
|
||||
isPhoneForbidden,
|
||||
isPrivateIpv4,
|
||||
MOBILE_LAN_DIRECT_HOST,
|
||||
MOBILE_LOOPBACK_HOST,
|
||||
MobileLinkError,
|
||||
type MobileLinkEvent,
|
||||
normalizePairCode,
|
||||
PAIR_CODE_ALPHABET,
|
||||
pairLookup,
|
||||
parseFirewallRuleOutput,
|
||||
parseMobileLinkStored,
|
||||
publishPairOffer,
|
||||
selectLanAddresses
|
||||
} from './mobile-lan-proxy'
|
||||
|
||||
@@ -191,9 +197,9 @@ function wsOpen(url: string, ca: string, extra: Record<string, unknown> = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
const connectOutcome = (port: number) =>
|
||||
const connectOutcome = (port: number, host = '127.0.0.1') =>
|
||||
new Promise<string>(resolve => {
|
||||
const socket = net.connect(port, '127.0.0.1')
|
||||
const socket = net.connect(port, host)
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy()
|
||||
@@ -698,15 +704,57 @@ describe('LAN proxy: TLS, listening and logs', () => {
|
||||
})
|
||||
|
||||
// ── controller ──────────────────────────────────────────────────────────────
|
||||
// Stand-in for the LAN bind (production: 0.0.0.0). Still loopback, so no test socket leaves this machine,
|
||||
// but it is a DIFFERENT address from 127.0.0.1, which is what "listening on the LAN or not" needs.
|
||||
const LAN_HOST = '127.0.0.2'
|
||||
const RELAY_IDS = ['3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77', '5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f']
|
||||
const HOST_TOKENS = RELAY_IDS.map((_id, index) => `tsmr_${String.fromCharCode(65 + index).repeat(43)}`)
|
||||
|
||||
/** Host socket that opens at once and never carries traffic; enough for the controller tests. */
|
||||
class FakeHostSocket extends EventEmitter {
|
||||
sent: (Buffer | string)[] = []
|
||||
closed: null | number = null
|
||||
bufferedAmount = 0
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
public headers: Record<string, string>
|
||||
) {
|
||||
super()
|
||||
setTimeout(() => this.emit('open'), 0)
|
||||
}
|
||||
|
||||
send(data: Buffer | string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.closed === null) {
|
||||
this.closed = code
|
||||
setTimeout(() => this.emit('close', code), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkHarness(over: Record<string, any> = {}) {
|
||||
const vault = { value: { accountToken: 'eski-alan' } as Record<string, any> }
|
||||
const store = { value: null as any, saves: 0 }
|
||||
const store = { value: (over.stored ?? null) as any, saves: 0 }
|
||||
const firewallCalls: string[] = []
|
||||
const firewallResults: any[] = over.firewallResults ?? []
|
||||
const fetchCalls: { url: string; init: any }[] = []
|
||||
const events: MobileLinkEvent[] = []
|
||||
const logs: string[] = []
|
||||
const state = { remote: false, offerStatus: over.offerStatus ?? 201 }
|
||||
const hostSockets: FakeHostSocket[] = []
|
||||
|
||||
const state = {
|
||||
remote: false,
|
||||
offerStatus: over.offerStatus ?? 201,
|
||||
/** false = `POST /sessions` fails (no internet). */
|
||||
sessions: over.sessions ?? true,
|
||||
sessionCount: 0,
|
||||
/** What `netsh … show rule` would answer. */
|
||||
rule: (over.rule ?? { state: 'absent' }) as any
|
||||
}
|
||||
|
||||
const deps = {
|
||||
vault: {
|
||||
@@ -728,17 +776,39 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
addRule: async (port: number) => {
|
||||
firewallCalls.push(`add:${port}`)
|
||||
|
||||
return firewallResults.shift() ?? { ok: true }
|
||||
const result = firewallResults.shift() ?? { ok: true }
|
||||
|
||||
if (result.ok) {
|
||||
state.rule = { state: 'present', ports: [port] }
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
removeRule: async () => {
|
||||
firewallCalls.push('remove')
|
||||
state.rule = { state: 'absent' }
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
queryRule: async () => {
|
||||
firewallCalls.push('query')
|
||||
|
||||
return structuredClone(state.rule)
|
||||
}
|
||||
},
|
||||
fetch: async (url: string, init: any) => {
|
||||
fetchCalls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/mobile-relay/sessions')) {
|
||||
if (!state.sessions) {
|
||||
throw new Error('network down')
|
||||
}
|
||||
|
||||
const index = state.sessionCount++
|
||||
|
||||
return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) }
|
||||
}
|
||||
|
||||
if (state.offerStatus === 'throw') {
|
||||
throw new Error('network down')
|
||||
}
|
||||
@@ -752,11 +822,18 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
|
||||
return { status: 200, json: async () => ({ cancelled: true }) }
|
||||
},
|
||||
relayConnect: (url: string, headers: Record<string, string>) => {
|
||||
const socket = new FakeHostSocket(url, headers)
|
||||
|
||||
hostSockets.push(socket)
|
||||
|
||||
return socket
|
||||
},
|
||||
addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'],
|
||||
version: '1.0.0-test',
|
||||
pcName: 'TEST-PC',
|
||||
platform: 'win32',
|
||||
listenHost: '127.0.0.1',
|
||||
listenHost: LAN_HOST,
|
||||
ports: [0],
|
||||
log: (line: string) => logs.push(line)
|
||||
}
|
||||
@@ -769,7 +846,11 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
return link
|
||||
}
|
||||
|
||||
return { link: make(), make, vault, store, firewallCalls, fetchCalls, events, logs, state }
|
||||
/** Firewall calls that change something (the read-only query is asked often). */
|
||||
const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query')
|
||||
const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers'))
|
||||
|
||||
return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets }
|
||||
}
|
||||
|
||||
describe('mobile link controller', () => {
|
||||
@@ -781,8 +862,9 @@ describe('mobile link controller', () => {
|
||||
const enabled = await h.link.enable()
|
||||
const port = enabled.port as number
|
||||
|
||||
expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'ok', remote: false })
|
||||
expect(h.firewallCalls).toEqual([`add:${port}`])
|
||||
expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'needed', remote: false })
|
||||
// Turning the feature on never touches the firewall (no UAC prompt): only the read-only query runs.
|
||||
expect(h.firewallWrites()).toEqual([])
|
||||
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
||||
|
||||
const pairing = await h.link.pairBegin()
|
||||
@@ -794,9 +876,8 @@ describe('mobile link controller', () => {
|
||||
|
||||
expect(cancelled).toMatchObject({ listening: false, pairing: null })
|
||||
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
||||
// The firewall is asked once per port, not on every enable.
|
||||
await h.link.enable()
|
||||
expect(h.firewallCalls).toEqual([`add:${port}`])
|
||||
expect(h.firewallWrites()).toEqual([])
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
@@ -811,13 +892,15 @@ describe('mobile link controller', () => {
|
||||
const fp = h.vault.value.lanTls.fingerprint
|
||||
|
||||
expect(pairing.code).toMatch(new RegExp(`^[${PAIR_CODE_ALPHABET}]{8}$`))
|
||||
expect(pairing.qr).toBe(`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${pairing.code}`)
|
||||
// Default (direct LAN access off): no `u`, only the fingerprint, the code and the relay id.
|
||||
expect(pairing.qr).toBe(`aiturk://pair?v=1&fp=${fp}&c=${pairing.code}&r=${RELAY_IDS[0]}`)
|
||||
expect(pairing.securityCode).toBe(fp.slice(0, 8))
|
||||
expect(fp).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(pairing.codePath).toBe('pending')
|
||||
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||
|
||||
const offer = h.fetchCalls[0]
|
||||
const offer = h.offers()[0]
|
||||
const body = JSON.parse(offer.init.body)
|
||||
|
||||
expect(offer.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/offers')
|
||||
@@ -826,8 +909,9 @@ describe('mobile link controller', () => {
|
||||
expect(offer.init.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(body).toEqual({
|
||||
lookup: crypto.createHash('sha256').update(`aiturk-mobile-pair-v1:${pairing.code}`).digest('hex'),
|
||||
urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`],
|
||||
fp
|
||||
urls: [],
|
||||
fp,
|
||||
relay: RELAY_IDS[0]
|
||||
})
|
||||
expect(offer.init.body).not.toContain(pairing.code)
|
||||
expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code)
|
||||
@@ -922,7 +1006,7 @@ describe('mobile link controller', () => {
|
||||
expect(off).toMatchObject({ enabled: false, listening: false })
|
||||
expect(off.devices).toHaveLength(1)
|
||||
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
||||
expect(h.firewallCalls).toEqual([`add:${port}`]) // disabling never removes the rule
|
||||
expect(h.firewallWrites()).toEqual([]) // neither enabling nor disabling touches the rule
|
||||
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:not_enabled')
|
||||
|
||||
// Disabled at app start: no socket.
|
||||
@@ -938,7 +1022,7 @@ describe('mobile link controller', () => {
|
||||
const restarted = h.make()
|
||||
const resumed = await restarted.start()
|
||||
|
||||
expect(resumed).toMatchObject({ enabled: true, listening: true, port, firewall: 'ok' })
|
||||
expect(resumed).toMatchObject({ enabled: true, listening: true, port, lan: { direct: false, listening: false } })
|
||||
expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200)
|
||||
|
||||
await restarted.stop()
|
||||
@@ -952,7 +1036,7 @@ describe('mobile link controller', () => {
|
||||
await expect(h.link.enable()).rejects.toBeInstanceOf(MobileLinkError)
|
||||
await expect(h.link.enable()).rejects.toThrow('aiturk-mobile:remote_mode')
|
||||
expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, remote: true })
|
||||
expect(h.firewallCalls).toEqual([])
|
||||
expect(h.firewallWrites()).toEqual([])
|
||||
|
||||
h.state.remote = false
|
||||
await h.link.enable()
|
||||
@@ -962,26 +1046,126 @@ describe('mobile link controller', () => {
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('a refused UAC prompt leaves a clear state and "Yeniden dene" adds the rule', async () => {
|
||||
it('"İzin ver" is the only thing that adds the firewall rule; a refused UAC prompt leaves direct access off', async () => {
|
||||
const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] })
|
||||
const state = await h.link.enable()
|
||||
|
||||
expect(state).toMatchObject({ enabled: true, firewall: 'denied' })
|
||||
expect((await h.link.firewallRetry()).firewall).toBe('failed')
|
||||
expect((await h.link.firewallRetry()).firewall).toBe('ok')
|
||||
expect(h.firewallCalls).toHaveLength(3)
|
||||
expect((await h.link.firewallRetry()).firewall).toBe('ok')
|
||||
expect(h.firewallCalls).toHaveLength(3)
|
||||
expect((await h.link.firewallRemove()).firewall).toBe('needed')
|
||||
expect(h.firewallCalls.at(-1)).toBe('remove')
|
||||
expect(state).toMatchObject({ enabled: true, firewall: 'needed', lan: { direct: false, listening: false, firewall: 'missing' } })
|
||||
expect(h.firewallWrites()).toEqual([])
|
||||
expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } })
|
||||
expect(await h.link.firewallRetry()).toMatchObject({ firewall: 'failed', lan: { direct: false } })
|
||||
expect(await h.link.firewallAllow()).toMatchObject({ firewall: 'ok', lan: { direct: true, firewall: 'allowed' } })
|
||||
expect(h.firewallWrites()).toHaveLength(3)
|
||||
// The rule is there (asked from Windows, not remembered): no second prompt.
|
||||
expect((await h.link.lanAllow()).firewall).toBe('ok')
|
||||
expect(h.firewallWrites()).toHaveLength(3)
|
||||
expect(await h.link.firewallRemove()).toMatchObject({ firewall: 'needed', lan: { direct: false, firewall: 'missing' } })
|
||||
expect(h.firewallWrites().at(-1)).toBe('remove')
|
||||
await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled')
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('without a usable LAN address pairing is refused; a failing vault is reported by code only', async () => {
|
||||
const h = linkHarness({ addresses: ['8.8.8.8', '169.254.3.4'] })
|
||||
it('default bind is 127.0.0.1 only; direct LAN access moves the socket to the LAN bind and puts `u` into the QR and the offer', async () => {
|
||||
expect(MOBILE_LOOPBACK_HOST).toBe('127.0.0.1')
|
||||
expect(MOBILE_LAN_DIRECT_HOST).toBe('0.0.0.0')
|
||||
|
||||
const h = linkHarness()
|
||||
|
||||
await h.link.enable()
|
||||
|
||||
const first = await h.link.pairBegin()
|
||||
const port = first.port as number
|
||||
|
||||
// Listening, but only on loopback: the stand-in LAN address refuses the connection.
|
||||
expect(first).toMatchObject({ listening: true, lan: { direct: false, listening: false, port, firewall: 'missing' } })
|
||||
expect(await connectOutcome(port, '127.0.0.1')).toBe('open')
|
||||
expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED')
|
||||
expect(first.pairing!.qr).not.toContain('u=')
|
||||
await until(() => h.offers().length === 1)
|
||||
expect(JSON.parse(h.offers()[0].init.body)).toMatchObject({ urls: [], relay: RELAY_IDS[0] })
|
||||
|
||||
// The user allows direct access: rule added now, pairing on screen is cancelled, socket moves.
|
||||
const allowed = await h.link.lanAllow()
|
||||
|
||||
expect(h.firewallWrites()).toEqual([`add:${port}`])
|
||||
expect(allowed).toMatchObject({ pairing: null, listening: false, lan: { direct: true, listening: false, firewall: 'allowed' } })
|
||||
expect(h.store.value.lanDirect).toBe(true)
|
||||
|
||||
const second = await h.link.pairBegin()
|
||||
const fp = h.vault.value.lanTls.fingerprint
|
||||
|
||||
expect(second).toMatchObject({ listening: true, lan: { direct: true, listening: true, port } })
|
||||
expect(await connectOutcome(port, LAN_HOST)).toBe('open')
|
||||
expect(second.pairing!.qr).toBe(
|
||||
`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${second.pairing!.code}&r=${RELAY_IDS[0]}`
|
||||
)
|
||||
await until(() => h.offers().length === 2)
|
||||
expect(JSON.parse(h.offers()[1].init.body)).toMatchObject({
|
||||
urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`],
|
||||
relay: RELAY_IDS[0]
|
||||
})
|
||||
|
||||
// The rule disappears outside the IDE: the next state query notices and goes back to loopback.
|
||||
h.state.rule = { state: 'absent' }
|
||||
|
||||
const lost = await h.link.state()
|
||||
|
||||
expect(lost).toMatchObject({ listening: true, lan: { direct: true, listening: false, firewall: 'missing' } })
|
||||
expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED')
|
||||
expect(await connectOutcome(port, '127.0.0.1')).toBe('open')
|
||||
|
||||
// A rule for another port is not permission for this one.
|
||||
h.state.rule = { state: 'present', ports: [port === 47321 ? 47322 : 47321] }
|
||||
expect((await h.link.state()).lan.firewall).toBe('missing')
|
||||
h.state.rule = { state: 'unknown' }
|
||||
expect((await h.link.state()).lan.firewall).toBe('unknown')
|
||||
|
||||
const off = await h.link.lanDisallow()
|
||||
|
||||
expect(off).toMatchObject({ pairing: null, lan: { direct: false, listening: false } })
|
||||
expect(h.firewallWrites()).toEqual([`add:${port}`]) // turning it off needs no prompt
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('a record written by beta.15 (enabled, firewallPort set, no lanDirect) stays on loopback and raises no prompt', async () => {
|
||||
const id = '0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
const h = linkHarness({
|
||||
rule: { state: 'present', ports: [47321] },
|
||||
stored: { v: 1, enabled: true, port: null, firewallPort: 47321, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }] }
|
||||
})
|
||||
|
||||
const resumed = await h.link.start()
|
||||
|
||||
expect(resumed).toMatchObject({ enabled: true, listening: true, lan: { direct: false, listening: false } })
|
||||
expect(await connectOutcome(resumed.port as number, LAN_HOST)).toBe('ECONNREFUSED')
|
||||
expect(h.firewallWrites()).toEqual([])
|
||||
// The relay follows the same condition as the listener: a paired phone is enough.
|
||||
await until(() => h.events.some(event => event.state.relay.status === 'connected'))
|
||||
expect(h.hostSockets).toHaveLength(1)
|
||||
await h.link.stop()
|
||||
expect(h.hostSockets[0].closed).toBe(1000)
|
||||
expect((await h.link.state()).relay.status).toBe('off')
|
||||
})
|
||||
|
||||
it('without a relay registration "Telefon ekle" starts nothing unless direct LAN access is on', async () => {
|
||||
const h = linkHarness({ sessions: false, addresses: ['8.8.8.8', '169.254.3.4'] })
|
||||
|
||||
await h.link.enable()
|
||||
await expect(h.link.pairBegin()).rejects.toThrow(/^aiturk-mobile:relay_unavailable$/)
|
||||
expect(await h.link.state()).toMatchObject({ pairing: null, listening: false, relay: { ready: false, status: 'off' } })
|
||||
expect(h.offers()).toEqual([])
|
||||
|
||||
// Direct access on, but no usable LAN address either.
|
||||
await h.link.lanAllow()
|
||||
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address')
|
||||
|
||||
// Internet back: the relay alone is enough, even with no LAN address at all.
|
||||
h.state.sessions = true
|
||||
|
||||
const state = await h.link.pairBegin()
|
||||
|
||||
expect(state.pairing!.qr).toMatch(new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`))
|
||||
await h.link.stop()
|
||||
|
||||
const broken = createMobileLink({
|
||||
@@ -1007,6 +1191,71 @@ describe('mobile link controller', () => {
|
||||
await expect(broken.enable()).rejects.toThrow(/^aiturk-mobile:vault_unavailable$/)
|
||||
})
|
||||
|
||||
it('relay: same on/off condition as the listener, token only in the vault and the Authorization header, renewal drops the phones', async () => {
|
||||
const h = linkHarness()
|
||||
|
||||
expect((await h.link.enable()).relay).toEqual({ status: 'off', code: null, ready: false, reissued: false })
|
||||
expect(h.hostSockets).toHaveLength(0)
|
||||
|
||||
const state = await h.link.pairBegin()
|
||||
const port = state.port as number
|
||||
const ca = h.vault.value.lanTls.cert
|
||||
|
||||
await until(() => h.events.some(event => event.state.relay.status === 'connected'))
|
||||
expect(h.hostSockets[0].url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[0]}`)
|
||||
expect(h.hostSockets[0].headers).toEqual({ Authorization: `Bearer ${HOST_TOKENS[0]}` })
|
||||
|
||||
const sessions = h.fetchCalls.find(entry => entry.url.endsWith('/sessions'))!
|
||||
|
||||
expect(sessions.url).toBe('https://turkservis.online/v1/aiturk/mobile-relay/sessions')
|
||||
expect(sessions.init).toMatchObject({ method: 'POST', body: '{}', redirect: 'error' })
|
||||
expect(sessions.init.signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
// relayId on disk, hostToken only in the vault.
|
||||
expect(h.store.value.relayId).toBe(RELAY_IDS[0])
|
||||
expect(h.vault.value.relay).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] })
|
||||
expect(h.vault.value.accountToken).toBe('eski-alan')
|
||||
|
||||
const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } })
|
||||
|
||||
expect(claim.status).toBe(200)
|
||||
await until(() => h.events.some(event => event.type === 'paired'))
|
||||
expect((await h.link.state()).relay).toEqual({ status: 'connected', code: null, ready: true, reissued: false })
|
||||
// One registration for the whole life of the pairing: the phone's stored id must not change.
|
||||
expect(h.fetchCalls.filter(entry => entry.url.endsWith('/sessions'))).toHaveLength(1)
|
||||
|
||||
// "Röle kimliğini yenile".
|
||||
const renewed = await h.link.relayRenew()
|
||||
|
||||
expect(renewed).toMatchObject({ devices: [], listening: false, relay: { status: 'off', ready: true, reissued: true } })
|
||||
expect(h.store.value.relayId).toBe(RELAY_IDS[1])
|
||||
expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[1])
|
||||
expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status).not.toBe(200)
|
||||
await until(() => h.hostSockets[0].closed !== null)
|
||||
|
||||
const again = await h.link.pairBegin()
|
||||
|
||||
expect(again.pairing!.qr).toContain(`&r=${RELAY_IDS[1]}`)
|
||||
await until(() => h.events.at(-1)?.state.relay.status === 'connected')
|
||||
// Every socket after the renewal carries the new token; the old one is never sent again.
|
||||
expect(h.hostSockets.length).toBeGreaterThan(1)
|
||||
expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe(true)
|
||||
expect(h.hostSockets.at(-1)!.url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[1]}`)
|
||||
|
||||
// Renewal that cannot reach the gateway changes nothing.
|
||||
h.state.sessions = false
|
||||
await expect(h.link.relayRenew()).rejects.toThrow('aiturk-mobile:relay_unavailable')
|
||||
expect(h.store.value.relayId).toBe(RELAY_IDS[1])
|
||||
|
||||
// The host token never reaches a log line, an event, a state answer or the plain store.
|
||||
const exposed = [h.logs.join('\n'), JSON.stringify(h.events), JSON.stringify(await h.link.state()), JSON.stringify(h.store.value)].join('\n')
|
||||
|
||||
expect(exposed).not.toContain('tsmr_')
|
||||
expect(h.logs.filter(line => line.startsWith('relay '))).toEqual(expect.arrayContaining(['relay connecting', 'relay connected', 'relay off']))
|
||||
expect(h.logs.filter(line => line.startsWith('relay ')).every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))).toBe(true)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('a regenerated certificate drops phones paired against the old one', async () => {
|
||||
const h = linkHarness()
|
||||
|
||||
@@ -1142,7 +1391,153 @@ describe('pure helpers', () => {
|
||||
port: 99999,
|
||||
devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null]
|
||||
})
|
||||
).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }] })
|
||||
expect(parseMobileLinkStored(null)).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [] })
|
||||
).toEqual({
|
||||
enabled: false,
|
||||
port: null,
|
||||
firewallPort: null,
|
||||
fingerprint: null,
|
||||
devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }],
|
||||
lanDirect: false,
|
||||
relayId: null,
|
||||
relayReissued: false
|
||||
})
|
||||
expect(parseMobileLinkStored(null)).toEqual({
|
||||
enabled: false,
|
||||
port: null,
|
||||
firewallPort: null,
|
||||
fingerprint: null,
|
||||
devices: [],
|
||||
lanDirect: false,
|
||||
relayId: null,
|
||||
relayReissued: false
|
||||
})
|
||||
// Only an explicit `true` turns direct LAN access on; a relay id must be a uuid.
|
||||
expect(parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 })).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false })
|
||||
expect(parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true })).toMatchObject({
|
||||
lanDirect: true,
|
||||
relayId: RELAY_IDS[0],
|
||||
relayReissued: true
|
||||
})
|
||||
})
|
||||
|
||||
it('QR payload and rendezvous body: `r` / `relay` is a plain lower-case uuid, `u` / `urls` optional only next to it', async () => {
|
||||
const fp = 'f'.repeat(43)
|
||||
const relay = RELAY_IDS[0]
|
||||
|
||||
expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(`aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}`)
|
||||
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(
|
||||
`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}`
|
||||
)
|
||||
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH`)
|
||||
|
||||
// Anything that is not exactly a uuid is dropped, never echoed into the QR.
|
||||
for (const bad of [`https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`, relay.toUpperCase(), `${relay}&u=1.2.3.4:1`, '', 7 as any]) {
|
||||
expect(buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).not.toContain('r=')
|
||||
expect(() => buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).toThrow('aiturk-mobile:no_lan_address')
|
||||
}
|
||||
|
||||
const bodies: any[] = []
|
||||
|
||||
const fetcher = async (_url: string, init: any) => {
|
||||
bodies.push(JSON.parse(init.body))
|
||||
|
||||
return { status: 201, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) }
|
||||
}
|
||||
|
||||
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay })).not.toBeNull()
|
||||
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp })).not.toBeNull()
|
||||
// Neither a LAN url nor a relay: nothing is published.
|
||||
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' })).toBeNull()
|
||||
expect(bodies).toEqual([
|
||||
{ lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay },
|
||||
{ lookup: pairLookup('ABCDEFGH'), urls: ['https://10.0.0.5:47321'], fp }
|
||||
])
|
||||
})
|
||||
|
||||
it('firewall rule query: fixed netsh arguments; English and Turkish answers; present / absent / other port / disabled / unreadable', async () => {
|
||||
const query = firewallQueryCommand('C:\\Windows')
|
||||
|
||||
expect(query).toEqual({ file: 'C:\\Windows\\System32\\netsh.exe', args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi'] })
|
||||
expect(firewallQueryCommand("C:\\x'; calc; '").file).toBe('C:\\Windows\\System32\\netsh.exe')
|
||||
|
||||
const english = (port: string, enabled = 'Yes') =>
|
||||
[
|
||||
'',
|
||||
'Rule Name: AITURK IDE Telefon Baglantisi',
|
||||
'----------------------------------------------------------------------',
|
||||
`Enabled: ${enabled}`,
|
||||
'Direction: In',
|
||||
'Profiles: Private,Public',
|
||||
'Grouping: ',
|
||||
'LocalIP: Any',
|
||||
'RemoteIP: Any',
|
||||
'Protocol: TCP',
|
||||
`LocalPort: ${port}`,
|
||||
'RemotePort: Any',
|
||||
'Edge traversal: No',
|
||||
'Action: Allow',
|
||||
'Ok.',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
// Turkish Windows, as the bytes arrive when the console code page is read as latin1 (labels garbled on purpose).
|
||||
const turkish = (port: string, enabled = 'Evet') =>
|
||||
[
|
||||
'',
|
||||
'Kural Adý: AITURK IDE Telefon Baglantisi',
|
||||
'----------------------------------------------------------------------',
|
||||
`Etkin: ${enabled}`,
|
||||
'Yön: Gelen',
|
||||
'Profiller: Özel,Ortak',
|
||||
'Gruplandýrma: ',
|
||||
'YerelIP: Herhangi',
|
||||
'UzakIP: Herhangi',
|
||||
'Ýletiþim Kuralý: TCP',
|
||||
`YerelBağlantıNoktası: ${port}`,
|
||||
'UzakBağlantıNoktası: Herhangi',
|
||||
'Kenar geçiþi: Hayýr',
|
||||
'Eylem: Ýzin Ver',
|
||||
'Tamam.',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
expect(parseFirewallRuleOutput(english('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
|
||||
expect(parseFirewallRuleOutput(turkish('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
|
||||
expect(parseFirewallRuleOutput(english('47330'), 0)).toEqual({ state: 'present', ports: [47330] })
|
||||
expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({ state: 'present', ports: [47321, 47325, 47326] })
|
||||
// Two rules under the same name (the port changed once): both ports count.
|
||||
expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({ state: 'present', ports: [47321, 47322] })
|
||||
// A disabled rule allows nothing.
|
||||
expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] })
|
||||
expect(parseFirewallRuleOutput(turkish('47321', 'Hayýr'), 0)).toEqual({ state: 'present', ports: [] })
|
||||
expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({ state: 'absent' })
|
||||
expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ state: 'absent' })
|
||||
// No answer at all (netsh missing, timeout) is not "no rule".
|
||||
expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' })
|
||||
expect(parseFirewallRuleOutput('', 1)).toEqual({ state: 'unknown' })
|
||||
expect(parseFirewallRuleOutput('something unexpected', 0)).toEqual({ state: 'unknown' })
|
||||
|
||||
const ran: any[] = []
|
||||
|
||||
const firewall = createWindowsFirewall({
|
||||
platform: 'win32',
|
||||
env: { SystemRoot: 'C:\\Windows' },
|
||||
spawn: () => {
|
||||
throw new Error('the query must not use the elevated path')
|
||||
},
|
||||
run: async (file, args) => {
|
||||
ran.push({ file, args })
|
||||
|
||||
return { code: 0, stdout: english('47321') }
|
||||
}
|
||||
})
|
||||
|
||||
expect(await firewall.queryRule!()).toEqual({ state: 'present', ports: [47321] })
|
||||
expect(ran).toEqual([query])
|
||||
|
||||
const failing = createWindowsFirewall({ platform: 'win32', env: {}, spawn: () => ({ on: () => undefined }), run: async () => Promise.reject(new Error('x')) })
|
||||
|
||||
expect(await failing.queryRule!()).toEqual({ state: 'unknown' })
|
||||
expect(await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!()).toEqual({ state: 'unknown' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* mobile-relay-client.ts
|
||||
*
|
||||
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
|
||||
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
|
||||
* WebSocket to the gateway. For every phone stream the gateway announces
|
||||
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
|
||||
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
|
||||
*
|
||||
* It never terminates TLS and never looks at a byte: the TLS session is
|
||||
* between the phone (which pins the proxy's certificate) and the proxy. So the
|
||||
* gateway — and this file — only ever carry ciphertext, and every rule (device
|
||||
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
|
||||
* controller keeps it in the safeStorage vault). It is sent in the
|
||||
* `Authorization` header of the host WebSocket and nowhere else: not in a
|
||||
* log line, a status object or an error message.
|
||||
* - Log lines are status changes only.
|
||||
* - Errors are fixed codes.
|
||||
*
|
||||
* Everything that touches the outside is injected (WebSocket connector, fetch,
|
||||
* local TCP dial, timers, credential store) so the tests run against a fake
|
||||
* relay on 127.0.0.1 and a fake clock.
|
||||
*/
|
||||
|
||||
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
|
||||
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
|
||||
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Largest payload of one binary frame, either direction (contract §1). */
|
||||
export const RELAY_CHUNK_BYTES = 64 * 1024
|
||||
/** Stream id prefix of a binary frame: big-endian uint32. */
|
||||
export const RELAY_FRAME_HEADER_BYTES = 4
|
||||
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
|
||||
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
|
||||
/** Reconnect delays: 2 s, then growing to 30 s. */
|
||||
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
|
||||
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
|
||||
export const RELAY_SILENCE_MS = 75_000
|
||||
/** Concurrent phone streams (the gateway allows 16 per relay). */
|
||||
export const RELAY_MAX_STREAMS = 16
|
||||
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
|
||||
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||
|
||||
const BUFFER_RESUME_BYTES = 256 * 1024
|
||||
const BUFFER_POLL_MS = 25
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
|
||||
|
||||
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
|
||||
|
||||
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
|
||||
export function isRelayId(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value)
|
||||
}
|
||||
|
||||
export interface RelayCredentials {
|
||||
relayId: string
|
||||
hostToken: string
|
||||
}
|
||||
|
||||
export function isRelayCredentials(value: unknown): value is RelayCredentials {
|
||||
const row = value as null | RelayCredentials
|
||||
|
||||
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
|
||||
}
|
||||
|
||||
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
|
||||
export function parseRelaySession(data: unknown): null | RelayCredentials {
|
||||
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
||||
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
|
||||
|
||||
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
|
||||
}
|
||||
|
||||
export interface RelayFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
|
||||
|
||||
export type RelayFailureCode =
|
||||
| 'register_failed'
|
||||
| 'rate_limited'
|
||||
| 'vault_unavailable'
|
||||
| 'connect_failed'
|
||||
| 'token_rejected'
|
||||
| 'replaced'
|
||||
| 'busy'
|
||||
| 'timeout'
|
||||
|
||||
/** `POST /sessions`; a fixed failure code instead of any upstream text. */
|
||||
export async function registerRelaySession(fetcher: RelayFetch): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
try {
|
||||
const response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
if (response.status !== 201) {
|
||||
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||
}
|
||||
|
||||
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||
} catch {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── What the client needs from the outside ──────────────────────────────────
|
||||
|
||||
/** Host WebSocket (the `ws` package fits: it can send request headers). */
|
||||
export interface RelaySocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
/** A string is a text frame, a Buffer a binary frame. */
|
||||
send: (data: Buffer | string) => unknown
|
||||
close: (code?: number) => unknown
|
||||
terminate?: () => unknown
|
||||
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
|
||||
pause?: () => unknown
|
||||
resume?: () => unknown
|
||||
readonly bufferedAmount?: number
|
||||
}
|
||||
|
||||
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
|
||||
|
||||
export interface RelayWebSocketCtor {
|
||||
new (url: string, options?: Record<string, unknown>): any
|
||||
}
|
||||
|
||||
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
|
||||
export interface RelayLocalSocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
write: (data: Buffer) => boolean
|
||||
destroy: () => unknown
|
||||
pause: () => unknown
|
||||
resume: () => unknown
|
||||
readonly writableLength?: number
|
||||
}
|
||||
|
||||
/** null = the proxy is not listening right now. */
|
||||
export type RelayDial = () => null | RelayLocalSocket
|
||||
|
||||
/** One binary frame: stream id (big-endian uint32) + payload. */
|
||||
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
|
||||
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
|
||||
|
||||
frame.writeUInt32BE(id, 0)
|
||||
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Pieces of at most `size` bytes, in order, without copying. */
|
||||
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
|
||||
const parts: Buffer[] = []
|
||||
|
||||
for (let offset = 0; offset < chunk.length; offset += size) {
|
||||
parts.push(chunk.subarray(offset, offset + size))
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
|
||||
|
||||
// ── The client ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
|
||||
|
||||
export interface RelayState {
|
||||
status: RelayStatus
|
||||
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
|
||||
code: null | RelayFailureCode
|
||||
}
|
||||
|
||||
export interface RelayCredentialStore {
|
||||
/** null when there is no registration yet. Throwing = the vault is unusable. */
|
||||
load: () => Promise<null | RelayCredentials>
|
||||
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
|
||||
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
|
||||
clear: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface RelayClientDeps {
|
||||
connect: RelayConnect
|
||||
fetch: RelayFetch
|
||||
dial: RelayDial
|
||||
credentials: RelayCredentialStore
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
/** Status changes only. */
|
||||
log?: (line: string) => void
|
||||
onState?: (state: RelayState) => void
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
local: RelayLocalSocket
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export function createRelayClient(deps: RelayClientDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||||
|
||||
let state: RelayState = { status: 'off', code: null }
|
||||
let wanted = false
|
||||
let generation = 0
|
||||
let attempt = 0
|
||||
let socket: null | RelaySocket = null
|
||||
let credentials: null | RelayCredentials = null
|
||||
/** The gateway rejected the stored registration; the next one replaces it. */
|
||||
let rejected = false
|
||||
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
|
||||
let retryTimer: unknown = null
|
||||
let silenceTimer: unknown = null
|
||||
const streams = new Map<number, Stream>()
|
||||
|
||||
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||
if (state.status === status && state.code === code) {
|
||||
return
|
||||
}
|
||||
|
||||
state = { status, code }
|
||||
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
|
||||
|
||||
try {
|
||||
deps.onState?.(state)
|
||||
} catch {
|
||||
// a broken listener must not break the relay
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (retryTimer !== null) {
|
||||
clearTimer(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
silenceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored registration, or a new one. One request at a time. */
|
||||
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
if (credentials) {
|
||||
return Promise.resolve(credentials)
|
||||
}
|
||||
|
||||
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
|
||||
let stored: null | RelayCredentials
|
||||
|
||||
try {
|
||||
stored = await deps.credentials.load()
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
if (isRelayCredentials(stored)) {
|
||||
credentials = stored
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
|
||||
if ('error' in fresh) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: rejected })
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
rejected = false
|
||||
credentials = fresh
|
||||
|
||||
return fresh
|
||||
})().finally(() => {
|
||||
registering = null
|
||||
})
|
||||
|
||||
return registering
|
||||
}
|
||||
|
||||
function dropStreams() {
|
||||
for (const stream of streams.values()) {
|
||||
stream.local.destroy()
|
||||
}
|
||||
|
||||
streams.clear()
|
||||
}
|
||||
|
||||
function retry(code: RelayFailureCode, delay?: number) {
|
||||
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
|
||||
|
||||
attempt++
|
||||
setState('failed', code)
|
||||
retryTimer = setTimer(() => {
|
||||
retryTimer = null
|
||||
void connect()
|
||||
}, wait)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!wanted || socket) {
|
||||
return
|
||||
}
|
||||
|
||||
const mine = ++generation
|
||||
|
||||
clearTimers()
|
||||
setState('connecting')
|
||||
|
||||
const current = await register()
|
||||
|
||||
if (!wanted || mine !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('error' in current) {
|
||||
retry(current.error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let ws: RelaySocket
|
||||
|
||||
try {
|
||||
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
|
||||
} catch {
|
||||
retry('connect_failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket = ws
|
||||
|
||||
const send = (data: Buffer | string) => {
|
||||
if (socket === ws) {
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {
|
||||
// the close event follows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
|
||||
|
||||
const watch = () => {
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
}
|
||||
|
||||
silenceTimer = setTimer(() => {
|
||||
silenceTimer = null
|
||||
|
||||
if (socket === ws) {
|
||||
try {
|
||||
;(ws.terminate ?? ws.close).call(ws)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
|
||||
down(1006, 'timeout')
|
||||
}
|
||||
}, RELAY_SILENCE_MS)
|
||||
}
|
||||
|
||||
const down = (code: number, reason?: RelayFailureCode) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
socket = null
|
||||
clearTimers()
|
||||
dropStreams()
|
||||
|
||||
if (!wanted) {
|
||||
setState('off')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
|
||||
// (unused for 30 days). Either way the registration is dead: take a new one.
|
||||
if (code === 4401 || code === 4404) {
|
||||
credentials = null
|
||||
rejected = true
|
||||
void deps.credentials
|
||||
.clear()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (wanted && socket === null && retryTimer === null && mine === generation) {
|
||||
retry('token_rejected')
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 4409) {
|
||||
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
|
||||
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4429: the relay service is full; the normal growing delay applies.
|
||||
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
|
||||
}
|
||||
|
||||
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
|
||||
const stalled = new Set<Stream>()
|
||||
|
||||
const unstall = (stream: Stream) => {
|
||||
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
|
||||
ws.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
|
||||
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
|
||||
if (streams.get(id) !== stream) {
|
||||
return
|
||||
}
|
||||
|
||||
streams.delete(id)
|
||||
unstall(stream)
|
||||
stream.local.destroy()
|
||||
|
||||
if (tell) {
|
||||
control({ t: 'close', id, code })
|
||||
}
|
||||
}
|
||||
|
||||
const openStream = (id: number) => {
|
||||
if (streams.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (streams.size >= RELAY_MAX_STREAMS) {
|
||||
return control({ t: 'close', id, code: 4429 })
|
||||
}
|
||||
|
||||
let local: null | RelayLocalSocket
|
||||
|
||||
try {
|
||||
local = deps.dial()
|
||||
} catch {
|
||||
local = null
|
||||
}
|
||||
|
||||
if (!local) {
|
||||
return control({ t: 'close', id, code: 4503 })
|
||||
}
|
||||
|
||||
const stream: Stream = { local, paused: false }
|
||||
let failed = false
|
||||
|
||||
const live = () => streams.get(id) === stream
|
||||
|
||||
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
|
||||
const drain = () => {
|
||||
if (!live() || socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stream.paused = false
|
||||
local.resume()
|
||||
}
|
||||
|
||||
streams.set(id, stream)
|
||||
local.on('connect', () => {
|
||||
if (live()) {
|
||||
control({ t: 'opened', id })
|
||||
}
|
||||
})
|
||||
local.on('data', (chunk: Buffer) => {
|
||||
if (!live()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bytes are forwarded exactly as read: no parsing, no TLS here.
|
||||
for (const part of splitRelayChunks(chunk)) {
|
||||
send(encodeRelayFrame(id, part))
|
||||
}
|
||||
|
||||
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
stream.paused = true
|
||||
local.pause()
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
}
|
||||
})
|
||||
local.on('drain', () => unstall(stream))
|
||||
local.on('error', () => {
|
||||
failed = true
|
||||
})
|
||||
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
|
||||
}
|
||||
|
||||
ws.on('open', () => {
|
||||
if (socket === ws) {
|
||||
attempt = 0
|
||||
setState('connected')
|
||||
watch()
|
||||
}
|
||||
})
|
||||
ws.on('close', (code: number) => down(Number(code) || 1006))
|
||||
ws.on('error', () => down(1006))
|
||||
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
|
||||
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
|
||||
const status = Number(res?.statusCode)
|
||||
|
||||
res?.resume?.()
|
||||
req?.destroy?.()
|
||||
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
|
||||
})
|
||||
ws.on('message', (data: unknown, isBinary?: boolean) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
watch()
|
||||
|
||||
if (isBinary) {
|
||||
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
|
||||
|
||||
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = frame.readUInt32BE(0)
|
||||
const stream = streams.get(id)
|
||||
|
||||
// Late or unknown ids are ignored.
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
|
||||
|
||||
const pending = stream.local.writableLength ?? 0
|
||||
|
||||
// Gateway → local: a proxy that does not read must not make this process hold the data.
|
||||
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
|
||||
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
endStream(id, stream, 4408, true)
|
||||
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
|
||||
stalled.add(stream)
|
||||
ws.pause()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let message: any
|
||||
|
||||
try {
|
||||
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'ping') {
|
||||
return control({ t: 'pong' })
|
||||
}
|
||||
|
||||
if (!validStreamId(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'open') {
|
||||
return openStream(message.id)
|
||||
}
|
||||
|
||||
if (message.t === 'close') {
|
||||
const stream = streams.get(message.id)
|
||||
|
||||
if (stream) {
|
||||
endStream(message.id, stream, 1000, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const api = {
|
||||
state: (): RelayState => state,
|
||||
|
||||
/** Open phone streams right now. */
|
||||
streams: (): number => streams.size,
|
||||
|
||||
/**
|
||||
* Makes sure a registration exists (stored or new) and returns its id; null
|
||||
* when the gateway or the vault is unavailable. Does not open the socket.
|
||||
*/
|
||||
async ensureRegistered(): Promise<null | string> {
|
||||
const current = await register()
|
||||
|
||||
return 'error' in current ? null : current.relayId
|
||||
},
|
||||
|
||||
/**
|
||||
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
|
||||
* only once the new one is stored; false = nothing changed.
|
||||
*/
|
||||
async renew(): Promise<boolean> {
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
|
||||
if ('error' in fresh) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
credentials = fresh
|
||||
rejected = false
|
||||
|
||||
if (wanted) {
|
||||
// Drop the socket that was authenticated with the old token and come back with the new one.
|
||||
api.stop()
|
||||
api.start()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
start() {
|
||||
if (wanted) {
|
||||
return
|
||||
}
|
||||
|
||||
wanted = true
|
||||
attempt = 0
|
||||
void connect()
|
||||
},
|
||||
|
||||
stop() {
|
||||
wanted = false
|
||||
generation++
|
||||
clearTimers()
|
||||
|
||||
const ws = socket
|
||||
|
||||
socket = null
|
||||
dropStreams()
|
||||
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
setState('off')
|
||||
}
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
export type RelayClient = ReturnType<typeof createRelayClient>
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.15",
|
||||
"version": "1.0.0-beta.16",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.15",
|
||||
"version": "1.0.0-beta.16",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
Reference in new issue
Block a user