Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ed9a6b2de |
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