Compare commits

...
Author SHA1 Message Date
yilsemandClaude Opus 5.5 58dbb4523b AITURK IDE 1.0.0-beta.17: telefon kodu yayini yeniden denenir
Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive
baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes
dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu).

- publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429
  yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin
  yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter).
- Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar.
- Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi
  yeniden denenmez).
- Gunluge yalniz "pair offer published / failed after N tries" duser; kod,
  lookup ve jeton yazilmaz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 14:27:59 +03:00
yilsemandClaude Opus 5.5 2ed9a6b2de feat(aiturk): phone link works from any network through an end-to-end encrypted tunnel (1.0.0-beta.16)
The phone link no longer depends on the local network. The IDE dials out to
turkservis.online and bridges tunnel streams to its own TLS proxy on
loopback; the phone runs TLS to the pinned IDE certificate inside the
tunnel, so the relay only carries ciphertext. The proxy binds to 127.0.0.1
by default and enabling the feature never asks for a firewall rule; direct
same-network access is an explicit option. The window shows the real relay,
listener and firewall state, and pairing no longer needs local addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:22:51 +03:00
13 changed files with 3608 additions and 180 deletions
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
AITURK_CLI_KEY_HOLD_MS,
AITURK_LOGIN_START_RETRY_DELAY_MS,
type AiturkCliExit,
AiturkLoginError,
createAiturkCliRunner,
@@ -38,6 +39,8 @@ const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'a
function harness(
options: {
start?: { status: number; body?: unknown }
/** Thrown by successive `device-auth/start` calls before any answer is given. */
startErrors?: Error[]
polls?: ({ status: number; body?: unknown } | Error)[]
cliExits?: AiturkCliExit[]
validate?: unknown
@@ -47,6 +50,7 @@ function harness(
) {
const calls: { url: string; init: any }[] = []
const polls = [...(options.polls ?? [])]
const startErrors = [...(options.startErrors ?? [])]
const cliExits = [...(options.cliExits ?? [0])]
const clock = { now: 1_000_000 }
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
@@ -55,6 +59,12 @@ function harness(
calls.push({ url, init })
if (url.endsWith('/start')) {
const failure = startErrors.shift()
if (failure) {
throw failure
}
const r = options.start ?? { status: 201, body: startBody() }
return { status: r.status, json: async () => r.body }
@@ -154,6 +164,47 @@ describe('aiturk login: start', () => {
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
})
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
const h = harness({ startErrors: [new TypeError('fetch failed')] })
const pending = h.login.start()
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
expect(h.openExternal).not.toHaveBeenCalled()
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
// One code only: the approval page is opened once.
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
})
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
const failing = twice.login.start().catch(e => e)
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
expect((await failing).message).toBe('aiturk-login:unavailable')
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
for (const status of [500, 503, 429, 422]) {
const answered = harness({ start: { status } })
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
expect(answered.timers).toHaveLength(0)
}
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
expect(timedOut.timers).toHaveLength(0)
})
it('rejects malformed codes', async () => {
await expect(
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
+24 -9
View File
@@ -22,6 +22,8 @@
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
export const AITURK_CLI_TIMEOUT_MS = 60_000
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
@@ -237,16 +239,29 @@ export function createAiturkLogin(deps: AiturkLoginDeps) {
await cancelOnServer(previous.deviceCode)
}
let response: AiturkFetchResponse
let response: AiturkFetchResponse | undefined
try {
response = await post('/v1/aiturk/device-auth/start', null, {
client: 'ide',
device_name: deviceName(deps.hostname()),
also_cli: true
})
} catch {
throw new AiturkLoginError('unavailable')
// A long-idle process can fail its first request on a dead pooled
// connection. Only a THROWN network error (no HTTP answer, so no code was
// handed to us) is tried once more; an HTTP answer is never retried, so
// one start cannot knowingly leave two codes behind. A timeout already
// used its whole budget and is not retried.
for (let attempt = 1; response === undefined; attempt++) {
try {
response = await post('/v1/aiturk/device-auth/start', null, {
client: 'ide',
device_name: deviceName(deps.hostname()),
also_cli: true
})
} catch (error) {
const name = (error as null | { name?: unknown })?.name
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
throw new AiturkLoginError('unavailable')
}
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
}
}
if (response.status === 429) {
+22 -4
View File
@@ -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|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
rememberLog(`[mobile-lan] ${line}`)
}
}
+63 -29
View File
@@ -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ı.',
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
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.',
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
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,8 +82,9 @@ 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'],
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
['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'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
let current = null, drawnQr = '', busy = false
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
@@ -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,10 @@ 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]
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
$('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 +140,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 +157,10 @@ 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')) })
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
$('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)
+11
View File
@@ -14,3 +14,14 @@
.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 }
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
+9 -2
View File
@@ -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>
@@ -15,7 +20,9 @@
<p id="t-or-code"></p>
<p id="pair-code" class="code" aria-live="off"></p>
<p id="pair-code-path"></p>
<button id="pair-republish" class="link" hidden></button>
<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 +30,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>
+8 -1
View File
@@ -20,9 +20,16 @@ export function createMobileWindow({ assets, link, show = true }) {
disable: () => link.disable(),
'pair.begin': () => link.pairBegin(),
'pair.cancel': () => link.pairCancel(),
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
'pair.republish': () => link.pairRepublish(),
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) => {
+765 -34
View File
@@ -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'
@@ -18,20 +19,28 @@ import { WebSocket, WebSocketServer } from 'ws'
import { createSelfSignedCertificate } from './mobile-lan-cert'
import {
buildPairQrPayload,
cancelPairOffer,
createLanProxy,
createMobileLink,
createWindowsFirewall,
firewallCommand,
firewallQueryCommand,
formatPairCode,
generatePairCode,
isPhoneForbidden,
isPrivateIpv4,
MOBILE_LAN_DIRECT_HOST,
MOBILE_LOOPBACK_HOST,
MobileLinkError,
type MobileLinkEvent,
normalizePairCode,
PAIR_CODE_ALPHABET,
PAIR_OFFER_MAX_TRIES,
PAIR_OFFER_RETRY_DELAYS_MS,
pairLookup,
parseFirewallRuleOutput,
parseMobileLinkStored,
publishPairOffer,
selectLanAddresses
} from './mobile-lan-proxy'
@@ -191,9 +200,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 +707,66 @@ 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[] = []
/** Outcomes of successive `POST /offers` calls (status or 'throw'); afterwards `state.offerStatus` applies. */
const offerPlan: (number | 'throw')[] = [...(over.offerPlan ?? [])]
/** Waits the controller asked for between tries (real values); they are shortened to 1 ms here. */
const retrySleeps: number[] = []
/** With `holdRetries` a wait ends only when the test calls the function. */
const heldRetries: (() => void)[] = []
const RETRY_WAITS = new Set<number>([...PAIR_OFFER_RETRY_DELAYS_MS])
const state = {
remote: false,
offerStatus: over.offerStatus ?? 201,
/** While set, `POST /offers` answers only after it resolves. */
offerGate: null as null | Promise<void>,
/** 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,36 +788,89 @@ 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 (state.offerStatus === 'throw') {
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] }) }
}
const isOffer = url.endsWith('/offers')
const offerOutcome = isOffer && offerPlan.length ? offerPlan.shift() : state.offerStatus
if (isOffer && state.offerGate) {
await state.offerGate
}
if (offerOutcome === 'throw') {
throw new Error('network down')
}
if (url.endsWith('/offers')) {
if (isOffer) {
return {
status: state.offerStatus,
status: offerOutcome,
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
}
}
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],
// Real timers, except the waits between tries of a request: those are recorded and cut to 1 ms.
setTimer: (fn: () => void, ms: number) => {
if (!RETRY_WAITS.has(ms)) {
return setTimeout(fn, ms)
}
retrySleeps.push(ms)
if (over.holdRetries) {
heldRetries.push(fn)
return null
}
return setTimeout(fn, 1)
},
clearTimer: (handle: unknown) => clearTimeout(handle as NodeJS.Timeout),
log: (line: string) => logs.push(line)
}
@@ -769,7 +882,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, retrySleeps, heldRetries }
}
describe('mobile link controller', () => {
@@ -781,8 +898,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 +912,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 +928,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 +945,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 +1042,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 +1058,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 +1072,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 +1082,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 +1227,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()
@@ -1029,6 +1314,306 @@ describe('mobile link controller', () => {
})
})
describe('mobile link controller: rendezvous record retries', () => {
const lookupOf = (h: ReturnType<typeof linkHarness>, index: number) => JSON.parse(h.offers()[index].init.body).lookup
const cancels = (h: ReturnType<typeof linkHarness>) => h.fetchCalls.filter(entry => entry.url.endsWith('/cancel'))
const noSecretInLogs = (h: ReturnType<typeof linkHarness>, code: string) => {
const text = h.logs.join('\n')
expect(text).not.toContain(code)
expect(text).not.toContain(pairLookup(code))
expect(text).not.toContain('tsmp_')
}
it('first request rejects, the second answers 201: the code path becomes ready (one lost request does not close it)', async () => {
const h = linkHarness({ offerPlan: ['throw', 201] })
await h.link.enable()
const state = await h.link.pairBegin()
expect(state.pairing!.codePath).toBe('pending')
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
expect(h.offers()).toHaveLength(2)
expect(h.retrySleeps).toEqual([500])
// Same record both times, and each try has its own timeout signal.
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
expect(h.offers()[1].init.signal).not.toBe(h.offers()[0].init.signal)
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer published'])
noSecretInLogs(h, state.pairing!.code)
await h.link.stop()
})
it('two 503 answers, then 201: ready on the third try', async () => {
const h = linkHarness({ offerPlan: [503, 503, 201] })
await h.link.enable()
await h.link.pairBegin()
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
expect(h.offers()).toHaveLength(3)
expect(h.retrySleeps).toEqual([500, 1500])
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
await h.link.stop()
})
it('a lost answer followed by 409 means the first request landed: ready, and no cancel is sent without a token', async () => {
const h = linkHarness({ offerPlan: ['throw', 409] })
await h.link.enable()
await h.link.pairBegin()
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
expect(h.offers()).toHaveLength(2)
expect((await h.link.pairCancel()).pairing).toBeNull()
await new Promise(resolve => setTimeout(resolve, 50))
expect(cancels(h)).toEqual([])
await h.link.stop()
expect(cancels(h)).toEqual([])
})
it('422 is final: one try, unavailable', async () => {
const h = linkHarness({ offerPlan: [422, 201] })
await h.link.enable()
const state = await h.link.pairBegin()
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
await new Promise(resolve => setTimeout(resolve, 50))
expect(h.offers()).toHaveLength(1)
expect(h.retrySleeps).toEqual([])
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 1 try'])
noSecretInLogs(h, state.pairing!.code)
await h.link.stop()
})
it('four failing tries: unavailable only after the fourth, never earlier', async () => {
const h = linkHarness({ offerPlan: ['throw', 503, 429, 'throw', 201], holdRetries: true })
await h.link.enable()
const state = await h.link.pairBegin()
for (let tries = 1; tries <= 3; tries++) {
await until(() => h.offers().length === tries && h.heldRetries.length === 1)
// Still being tried: the window keeps saying "hazırlanıyor…".
expect((await h.link.state()).pairing!.codePath).toBe('pending')
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
h.heldRetries.shift()!()
}
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
expect(h.offers()).toHaveLength(4)
expect(h.retrySleeps).toEqual([500, 1500, 3000])
expect(h.heldRetries).toEqual([])
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 4 tries'])
noSecretInLogs(h, state.pairing!.code)
await h.link.stop()
})
it('cancelling the pairing stops the tries; a record that lands after the cancel is cancelled on the gateway', async () => {
const h = linkHarness({ offerPlan: ['throw', 201], holdRetries: true })
await h.link.enable()
await h.link.pairBegin()
await until(() => h.offers().length === 1 && h.heldRetries.length === 1)
expect((await h.link.pairCancel()).pairing).toBeNull()
// The wait ends after the pairing is gone: no further request.
h.heldRetries.shift()!()
await new Promise(resolve => setTimeout(resolve, 50))
expect(h.offers()).toHaveLength(1)
expect(cancels(h)).toEqual([])
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual([])
// A NEW pairing is not confused with the old one, and a request in flight when pairing ends is cleaned up.
let release: () => void = () => undefined
h.state.offerGate = new Promise<void>(resolve => {
release = resolve
})
await h.link.pairBegin()
await until(() => h.offers().length === 2)
await h.link.pairCancel()
release()
await until(() => cancels(h).length === 1)
expect(cancels(h)[0].init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`)
await h.link.stop()
})
it('pair.republish publishes the running pairing again: new request, same code, same QR, same expiry', async () => {
const h = linkHarness({ offerPlan: [422] })
await h.link.enable()
const begun = (await h.link.pairBegin()).pairing!
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
expect(h.offers()).toHaveLength(1)
const again = (await h.link.pairRepublish()).pairing!
expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' })
await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready')
expect(h.offers()).toHaveLength(2)
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
expect(JSON.parse(h.offers()[1].init.body)).toEqual(JSON.parse(h.offers()[0].init.body))
// Nothing to do while ready (or pending), and nothing without a pairing.
expect((await h.link.pairRepublish()).pairing).toMatchObject({ code: begun.code, codePath: 'ready' })
expect(h.offers()).toHaveLength(2)
// The code on screen still pairs the phone.
const claim = await call((await h.link.state()).port as number, 'POST', '/aiturk/pair/claim', {
ca: h.vault.value.lanTls.cert,
body: { code: begun.code, device_name: 'Pixel' }
})
expect(claim.status).toBe(200)
await until(() => h.events.some(event => event.type === 'paired'))
expect((await h.link.pairRepublish()).pairing).toBeNull()
expect(h.offers()).toHaveLength(2)
await h.link.stop()
})
it('pair.republish after four lost requests: a 409 on its first try is our own record (ready); after a 422 it is not', async () => {
const lost = linkHarness({ offerPlan: ['throw', 'throw', 'throw', 'throw', 409] })
await lost.link.enable()
await lost.link.pairBegin()
await until(() => lost.events.some(event => event.state.pairing?.codePath === 'unavailable'))
expect(lost.offers()).toHaveLength(4)
await lost.link.pairRepublish()
await until(() => lost.events.at(-1)?.state.pairing?.codePath === 'ready')
expect(lost.offers()).toHaveLength(5)
await lost.link.stop()
expect(cancels(lost)).toEqual([])
const refused = linkHarness({ offerPlan: [422, 409] })
await refused.link.enable()
await refused.link.pairBegin()
await until(() => refused.events.some(event => event.state.pairing?.codePath === 'unavailable'))
await refused.link.pairRepublish()
await until(() => refused.offers().length === 2 && refused.events.at(-1)?.state.pairing?.codePath === 'unavailable')
await refused.link.stop()
})
})
describe('publishPairOffer / cancelPairOffer', () => {
const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] }
const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }
function plan(outcomes: (number | 'throw')[]) {
const sleeps: number[] = []
const logs: string[] = []
let calls = 0
const fetcher = async () => {
const outcome = outcomes[calls++] ?? 201
if (outcome === 'throw') {
throw new Error('fetch failed')
}
return { status: outcome, json: async () => good }
}
const options = {
sleep: async (ms: number) => {
sleeps.push(ms)
},
log: (line: string) => logs.push(line)
}
return { fetcher, options, sleeps, logs, calls: () => calls }
}
it('waits 0.5 s, 1.5 s and 3 s between at most four tries', async () => {
const p = plan(['throw', 500, 429, 'throw', 201])
expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull()
expect(p.calls()).toBe(PAIR_OFFER_MAX_TRIES)
expect(p.calls()).toBe(4)
expect(p.sleeps).toEqual([500, 1500, 3000])
expect(p.sleeps).toEqual([...PAIR_OFFER_RETRY_DELAYS_MS])
expect(p.logs).toEqual(['offer failed after 4 tries'])
})
it('retries a thrown error, 5xx and 429; 201 ends it', async () => {
for (const first of ['throw', 500, 502, 503, 504, 429] as const) {
const p = plan([first, 201])
expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual(good)
expect(p.calls()).toBe(2)
expect(p.logs).toEqual(['offer published'])
}
})
it('does not retry 422 or any other final answer; a first-try 409 is somebody else’s record', async () => {
for (const status of [422, 400, 401, 403, 404, 409, 200, 302]) {
const p = plan([status, 201])
expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull()
expect(p.calls()).toBe(1)
expect(p.sleeps).toEqual([])
expect(p.logs).toEqual(['offer failed after 1 try'])
}
})
it('409 after a lost answer or a 5xx is our own record (no token); 409 after 429 is not', async () => {
for (const first of ['throw', 502] as const) {
const p = plan([first, 409])
expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual({ offerId: null, cancelToken: null })
expect(p.calls()).toBe(2)
expect(p.logs).toEqual(['offer published'])
}
const limited = plan([429, 409])
expect(await publishPairOffer(limited.fetcher, input, limited.options)).toBeNull()
expect(limited.calls()).toBe(2)
const told = plan([409])
expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null })
})
it('stops as soon as the pairing is over, without a log line', async () => {
const p = plan(['throw', 'throw', 201])
let over = false
const result = await publishPairOffer(p.fetcher, input, {
...p.options,
cancelled: () => over,
sleep: async ms => {
p.sleeps.push(ms)
over = true
}
})
expect(result).toBeNull()
expect(p.calls()).toBe(1)
expect(p.logs).toEqual([])
})
it('cancelPairOffer sends nothing when there is no token', async () => {
const urls: string[] = []
const fetcher = async (url: string) => {
urls.push(url)
return { status: 200, json: async () => ({}) }
}
await cancelPairOffer(fetcher, { offerId: null, cancelToken: null })
expect(urls).toEqual([])
await cancelPairOffer(fetcher, good)
expect(urls).toEqual(['https://turkservis.online/v1/aiturk/mobile-pair/cancel'])
})
})
describe('pure helpers', () => {
it('pairing code: alphabet, normalisation, display form, lookup digest', () => {
for (let i = 0; i < 200; i++) {
@@ -1142,7 +1727,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' })
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,733 @@
/**
* 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'
/** Wait before the single second try of a request whose first try never got an answer. */
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
export function isTimeoutError(error: unknown): boolean {
const name = (error as null | { name?: unknown })?.name
return name === 'TimeoutError' || name === 'AbortError'
}
export interface NetworkRetryOptions {
/** Injected wait (tests: fake clock). Default: a real timer. */
sleep?: (ms: number) => Promise<void>
retryDelayMs?: number
}
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
/**
* `POST /sessions`; a fixed failure code instead of any upstream text.
*
* A process that sat idle for a long time can fail its first request on a dead
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
*/
export async function registerRelaySession(
fetcher: RelayFetch,
options: NetworkRetryOptions = {}
): Promise<RelayCredentials | { error: RelayFailureCode }> {
const sleep = options.sleep ?? realSleep
for (let attempt = 1; ; attempt++) {
let response: RelayFetchResponse
try {
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)
})
} catch (error) {
if (attempt >= 2 || isTimeoutError(error)) {
return { error: 'register_failed' }
}
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
continue
}
try {
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>()
/** Same (injectable) clock as every other wait of this client. */
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
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, { sleep })
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, { sleep })
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>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.15",
"version": "1.0.0-beta.17",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
+1 -1
View File
@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "aiturk-ide",
"version": "1.0.0-beta.15",
"version": "1.0.0-beta.17",
"license": "MIT",
"dependencies": {
"@assistant-ui/core": "0.2.23",