ddeck-remote 0.0.0-stage → 0.9.7-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1088 @@
1
+ /**
2
+ * DDeck — the computer half.
3
+ *
4
+ * One job the person can see: show a code that turns their phone into a remote control
5
+ * for this computer. Everything a phone needs beyond that — the pairing handshake, the
6
+ * session credential, the proxy to the harness, the mobile page — lives behind it.
7
+ *
8
+ * The pairing window is deliberately short-lived and rotates on its own. A wedged QR on
9
+ * a screen is a credential sitting in the open; one that refreshes every few minutes is
10
+ * a credential with a shelf life, and the person never has to press "regenerate" because
11
+ * they were reading the instructions.
12
+ *
13
+ * @module dsh-remote
14
+ */
15
+
16
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
17
+ import { execFile, spawn } from 'node:child_process'
18
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
19
+ import { readFileSync } from 'node:fs'
20
+ import { homedir, networkInterfaces } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+ import { autoOrigins, defaultStateDirectory, openReadablePairing, startGateway } from './gateway.js'
23
+ import { renderQr as renderQrSvg } from './qr-bundle.js'
24
+ import { isTrustedAdminRequest } from './admin-trust.js'
25
+ import { PAIRING_TTL_MS } from './constants.js'
26
+
27
+ /** Stable Cordis plugin name; must equal the package name (see cordis.patch.yml). */
28
+ export const name = 'ddeck-remote'
29
+
30
+ /**
31
+ * The web server hosts our routes; `connection` supplies the launch-token URL the
32
+ * gateway exchanges for an upstream session. Without that exchange the harness answers
33
+ * every proxied request with 401 and the phone shows `upstream_unavailable`.
34
+ */
35
+ export const inject = ['webServer', 'connection']
36
+
37
+ /** Prefix every route of this plugin lives under. */
38
+ export const ROUTE_PREFIX = '/dsh-remote'
39
+
40
+ /**
41
+ * Prefix of the phone-facing page.
42
+ *
43
+ * The split is what protects the pairing window. A gateway in front of DSH proxies to
44
+ * it from loopback, so an address check cannot tell the person at the keyboard from a
45
+ * phone that is already paired — both arrive from 127.0.0.1. The split therefore has to
46
+ * be by *path*: the phone loads `${MOBILE_PREFIX}`, and the gateway refuses the whole of
47
+ * `${ROUTE_PREFIX}` for proxied traffic. Without that refusal a paired phone could open
48
+ * the pairing page and add itself a companion, which is the one escalation this whole
49
+ * scheme exists to prevent.
50
+ */
51
+ export const MOBILE_PREFIX = '/m'
52
+
53
+ /** How long a pairing window stays valid. */
54
+ const WINDOW_MS = PAIRING_TTL_MS
55
+
56
+ /** Версия плагина из единственного источника — plugin/package.json. */
57
+ const PLUGIN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
58
+
59
+ /**
60
+ * Where the plugin keeps its own small state.
61
+ *
62
+ * Not the gateway's state directory: this file holds the instance identity and the
63
+ * secret that derives pairing codes, and it belongs to the plugin that owns it.
64
+ *
65
+ * @returns {string} absolute path to the state file.
66
+ */
67
+ function statePath() {
68
+ return join(homedir(), '.dsh', 'dsh-remote', 'state.json')
69
+ }
70
+
71
+ /** File with the password that unlocks pairing from an already paired phone. */
72
+ function mobilePinPath() {
73
+ return join(homedir(), '.dsh', 'dsh-remote', 'mobile-pin.json')
74
+ }
75
+
76
+ /**
77
+ * Read the stored phone-pairing password record.
78
+ *
79
+ * The password is never stored in clear text: only a random salt and a scrypt
80
+ * hash. Missing or unreadable file means the feature is not configured.
81
+ *
82
+ * @returns {Promise<{salt: string, hash: string} | undefined>}
83
+ */
84
+ async function readMobilePin() {
85
+ try {
86
+ const parsed = JSON.parse(await readFile(mobilePinPath(), 'utf8'))
87
+ if (typeof parsed.salt !== 'string' || typeof parsed.hash !== 'string') return undefined
88
+ return { salt: parsed.salt, hash: parsed.hash }
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Compare a typed password with the stored scrypt record.
96
+ *
97
+ * @param {string} password typed password.
98
+ * @param {{salt: string, hash: string}} record stored salt/hash.
99
+ * @returns {boolean} true when the password matches.
100
+ */
101
+ function verifyMobilePin(password, record) {
102
+ const candidate = scryptSync(password, Buffer.from(record.salt, 'hex'), 32)
103
+ const expected = Buffer.from(record.hash, 'hex')
104
+ return candidate.length === expected.length && timingSafeEqual(candidate, expected)
105
+ }
106
+
107
+ /**
108
+ * Load or create the persistent identity.
109
+ *
110
+ * The instance id names this installation in a QR payload, and the secret turns a
111
+ * pairing token into a six-digit code. Both survive restarts: a phone that scanned a
112
+ * code must keep recognising the same computer, and a code shown a minute ago must
113
+ * still verify after a plugin reload.
114
+ *
115
+ * @returns {Promise<{ instanceId: string, secret: string, devices: object[] }>}
116
+ */
117
+ async function loadState() {
118
+ const file = statePath()
119
+ try {
120
+ const parsed = JSON.parse(await readFile(file, 'utf8'))
121
+ if (typeof parsed.instanceId === 'string' && typeof parsed.secret === 'string') {
122
+ return { devices: [], ...parsed }
123
+ }
124
+ } catch {
125
+ // First run, or a file we cannot read: a fresh identity is the safe answer.
126
+ }
127
+ const fresh = {
128
+ instanceId: randomBytes(16).toString('hex'),
129
+ secret: randomBytes(32).toString('hex'),
130
+ devices: [],
131
+ }
132
+ await mkdir(dirname(file), { recursive: true })
133
+ await writeFile(file, `${JSON.stringify(fresh, null, 2)}\n`, { mode: 0o600 })
134
+ return fresh
135
+ }
136
+
137
+ /**
138
+ * Derive the six-digit code for a pairing token.
139
+ *
140
+ * The code is not a second secret: it is the same window in a form a person can read
141
+ * aloud or type when the camera will not cooperate. Digits only, no letters — the
142
+ * alphabet question disappears, and there is nothing for a reader to confuse.
143
+ *
144
+ * @param {string} secret - per-installation secret.
145
+ * @param {string} token - pairing token of the open window.
146
+ * @returns {string} six digits, zero-padded.
147
+ */
148
+ function newCode() {
149
+ // Independent of the QR ticket on purpose: the ticket rotates every minute or so, and
150
+ // a code that changed with it could never be typed from a screenshot. Six digits from
151
+ // the whole range, zero-padded, so `000042` is a valid code.
152
+ return String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, '0')
153
+ }
154
+
155
+ /**
156
+ * The live pairing window, if one is open.
157
+ *
158
+ * Held in memory on purpose: closing the plugin, restarting DSH or a crash must not
159
+ * leave a usable credential behind, and a window is cheap to open again.
160
+ */
161
+ class PairingWindow {
162
+ constructor() {
163
+ /** @type {{ token: string, code: string, expiresAt: number, attempts: number } | undefined} */
164
+ this.current = undefined
165
+ }
166
+
167
+ /**
168
+ * Return the open window, opening a fresh one when the old one has expired.
169
+ *
170
+ * @returns {{ token: string, code: string, expiresAt: number }}
171
+ */
172
+ open() {
173
+ const now = Date.now()
174
+ if (this.current !== undefined && this.current.expiresAt > now) {
175
+ return this.current
176
+ }
177
+ // 24 bytes of entropy: a token that cannot be guessed from a photograph of the
178
+ // screen next to it, and short enough for a compact QR.
179
+ const token = randomBytes(24).toString('base64url')
180
+ this.current = { token, code: newCode(), expiresAt: now + WINDOW_MS }
181
+ return this.current
182
+ }
183
+
184
+ }
185
+
186
+ /**
187
+ * Render the phone's entry page.
188
+ *
189
+ * Two languages are served from one document: the page picks by `Accept-Language` and
190
+ * carries a switch, because the person holding the phone decides which language they
191
+ * read, and a product that guesses once and never asks gets it wrong for someone.
192
+ *
193
+ * @param {'ru' | 'en'} language
194
+ * @param {{ pairing: { code: string }, qrSvg: string }} view
195
+ * @returns {string} complete HTML document.
196
+ */
197
+ function entryPage(language, view) {
198
+ const ru = language === 'ru'
199
+ const text = {
200
+ heading: ru ? 'Подключение телефона' : 'Connect your phone',
201
+ lead: ru
202
+ ? 'Откройте приложение DDeck и наведите камеру на код.'
203
+ : 'Open the DDeck app and point its camera at the code.',
204
+ or: ru ? 'или введите код на телефоне' : 'or type this code on the phone',
205
+ where: ru ? 'телефон подключится к' : 'the phone will connect to',
206
+ alsoReachable: ru ? 'также доступен по' : 'also reachable at',
207
+ status: ru ? 'Плагин работает' : 'Plugin running',
208
+ noGateway: ru
209
+ ? 'Шлюз не запустился — телефон не подключится. Подробности в журнале DSH.'
210
+ : 'The gateway did not start — the phone cannot connect. See the DSH log.',
211
+ connected: ru ? 'Телефон подключён' : 'Phone connected',
212
+ connectedLead: ru ? 'Этот компьютер открыт для перечисленных устройств.' : 'This computer is open to the devices listed below.',
213
+ revoke: ru ? 'Отключить' : 'Disconnect',
214
+ pairAnother: ru ? 'Подключить ещё один телефон' : 'Connect another phone',
215
+ advanced: ru ? 'Дополнительно' : 'Advanced',
216
+ switchLabel: ru ? 'English' : 'Русский',
217
+ }
218
+ const switchHref = `${ROUTE_PREFIX}?lang=${ru ? 'en' : 'ru'}`
219
+ const paired = view.devices.length > 0
220
+ const deviceWord = (count) => {
221
+ if (!ru) return count === 1 ? 'device' : 'devices'
222
+ const mod10 = count % 10
223
+ const mod100 = count % 100
224
+ if (mod10 === 1 && mod100 !== 11) return 'устройство'
225
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'устройства'
226
+ return 'устройств'
227
+ }
228
+ const when = (time) => {
229
+ const locale = ru ? 'ru-RU' : 'en-GB'
230
+ return new Date(time).toLocaleString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
231
+ }
232
+ const advanced = [
233
+ [ru ? 'Адрес' : 'Address', view.origin],
234
+ [ru ? 'Отпечаток' : 'Fingerprint', String(view.instanceId).slice(0, 16) + '…'],
235
+ [ru ? 'Шлюз' : 'Gateway', view.gateway ? (ru ? 'работает' : 'running') : (ru ? 'не запущен' : 'not running')],
236
+ ]
237
+
238
+ return `<!doctype html>
239
+ <html lang="${language}">
240
+ <head>
241
+ <meta charset="utf-8">
242
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
243
+ <title>DDeck</title>
244
+ <style>
245
+ :root {
246
+ color-scheme: dark;
247
+ --bg: #12151c; --fg: #e6e8eb; --muted: #a8adb7; --line: #2a2f3a; --ok: #4caf7d;
248
+ }
249
+ * { box-sizing: border-box }
250
+ /* Сетка с одной колонкой фиксированной ширины: без этого колонка тянется по самому
251
+ * широкому содержимому, и в узком окне список устройств уводил всю страницу вправо —
252
+ * заголовок и адрес обрезались.
253
+ */
254
+ body { margin: 0; min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr);
255
+ place-items: center; padding: 0 1.5rem;
256
+ background: var(--bg); color: var(--fg);
257
+ font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif }
258
+ main { width: 100%; max-width: 26rem; min-width: 0; text-align: center; padding: 2rem 0 }
259
+ h1 { font-size: 1.375rem; font-weight: 600; margin: 0 0 .5rem }
260
+ p.lead { margin: 0 0 1.75rem; color: var(--muted) }
261
+ .qr { display: inline-block; padding: .75rem; border-radius: 1rem; background: #fff; line-height: 0 }
262
+ .qr svg { width: 15rem; height: 15rem; display: block }
263
+ .code-label { margin: 1.5rem 0 .375rem; font-size: .8125rem; letter-spacing: .04em;
264
+ text-transform: uppercase; color: var(--muted) }
265
+ .code { font: 600 2rem/1.1 ui-monospace, SFMono-Regular, Menlo, monospace;
266
+ letter-spacing: .28em; padding-left: .28em }
267
+ .where { margin: 1.5rem 0 0; font-size: .8125rem; color: var(--muted) }
268
+ .where code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg) }
269
+ .status { margin-top: 1rem; display: inline-flex; gap: .5rem; align-items: center;
270
+ padding: .4375rem .875rem; border: 1px solid var(--line); border-radius: 999px;
271
+ font-size: .8125rem; color: #8fbf9f }
272
+ .dot { width: .5rem; height: .5rem; border-radius: 50%; background: var(--ok) }
273
+ .bad { color: #e0a3a3; border-color: #4a2f2f }
274
+ .bad .dot { background: #d9534f }
275
+ footer { margin-top: 2rem }
276
+ a { color: var(--muted) }
277
+ .devices { list-style: none; margin: 0 0 .5rem; padding: 0; text-align: left;
278
+ border: 1px solid var(--line); border-radius: .875rem; overflow: hidden }
279
+ .devices li { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem; padding: .75rem 1rem }
280
+ .devices .name { overflow-wrap: anywhere }
281
+ .devices li + li { border-top: 1px solid var(--line) }
282
+ .devices .name { flex: 1 1 auto; font-weight: 500 }
283
+ .devices .meta { color: var(--muted); font-size: .8125rem }
284
+ .devices .revoke { border: 1px solid var(--line); border-radius: .5rem; background: transparent;
285
+ color: #e0a3a3; padding: .375rem .625rem; font: inherit; font-size: .8125rem; cursor: pointer;
286
+ /* 44 px — тот же минимум, что и в мобильном слое: палец не должен прицеливаться. */
287
+ min-height: 2.75rem }
288
+ .count { color: var(--muted); font-size: .8125rem; margin: 0 0 1.5rem }
289
+ details { margin-top: 1.5rem; text-align: left }
290
+ details > summary { cursor: pointer; color: var(--muted); font-size: .875rem;
291
+ list-style: none; display: flex; align-items: center; gap: .375rem; min-height: 2.75rem }
292
+ details > summary::-webkit-details-marker { display: none }
293
+ details > summary::after { content: '›'; transform: rotate(90deg); transition: transform .15s ease }
294
+ details[open] > summary::after { transform: rotate(-90deg) }
295
+ .pair-more > summary { justify-content: center }
296
+ .pair-more .qr { margin-top: .5rem }
297
+ .advanced dl { margin: .5rem 0 0; display: grid; grid-template-columns: auto 1fr; gap: .375rem .75rem }
298
+ .advanced dt { color: var(--muted); font-size: .8125rem }
299
+ .advanced dd { margin: 0; font-size: .8125rem; overflow-wrap: anywhere }
300
+ </style>
301
+ </head>
302
+ <body>
303
+ <main>
304
+ <h1>${paired ? text.connected : text.heading}</h1>
305
+ <p class="lead">${paired ? text.connectedLead : text.lead}</p>
306
+ ${paired
307
+ ? `<ul class="devices">${view.devices.map((device) => `<li><span class="name">${escapeHtml(device.label)}</span><span class="meta">${when(device.lastSeenAt)}</span><button type="button" class="revoke" data-id="${escapeHtml(device.id)}">${text.revoke}</button></li>`).join('')}</ul>
308
+ <p class="count">${view.devices.length} ${deviceWord(view.devices.length)}</p>
309
+ <details class="pair-more"><summary>${text.pairAnother}</summary><div class="qr" id="qr">${view.qrSvg}</div><p class="code-label">${text.or}</p><div class="code" id="code">${view.pairing.code}</div></details>`
310
+ : `<div class="qr" id="qr">${view.qrSvg}</div>
311
+ <p class="code-label">${text.or}</p>
312
+ <div class="code" id="code">${view.pairing.code}</div>`}
313
+ <p class="where">${text.where} <code>${view.origin}</code></p>
314
+ ${view.altOrigins && view.altOrigins.length > 0
315
+ ? `<p class="where alt">${text.alsoReachable} ${view.altOrigins.map((origin) => `<code>${escapeHtml(origin)}</code>`).join(', ')}</p>`
316
+ : ''}
317
+ <div class="status${view.gateway ? '' : ' bad'}"><span class="dot"></span>${view.gateway ? text.status : text.noGateway}</div>
318
+ <details class="advanced">
319
+ <summary>${text.advanced}</summary>
320
+ <dl>${advanced.map(([key, value]) => `<dt>${key}</dt><dd><code>${escapeHtml(String(value))}</code></dd>`).join('')}</dl>
321
+ </details>
322
+ <footer><a href="${switchHref}" rel="alternate" hreflang="${ru ? 'en' : 'ru'}">${text.switchLabel}</a></footer>
323
+ </main>
324
+ <script>
325
+ // The window rotates on its own; the page follows it instead of showing a dead code.
326
+ // A person reading the instructions should never come back to "code expired" and have
327
+ // to press something to continue.
328
+ (function () {
329
+ var qr = document.getElementById('qr')
330
+ var code = document.getElementById('code')
331
+ var shown = code ? code.textContent.trim() : ''
332
+ function refresh() {
333
+ fetch('${ROUTE_PREFIX}/pairing', { cache: 'no-store' })
334
+ .then(function (response) { return response.ok ? response.json() : null })
335
+ .then(function (next) {
336
+ if (!next || !next.code || next.code === shown) return
337
+ shown = next.code
338
+ code.textContent = next.code
339
+ if (next.qrSvg) qr.innerHTML = next.qrSvg
340
+ })
341
+ .catch(function () { /* offline: keep what is on screen */ })
342
+ }
343
+ document.addEventListener('click', function (event) {
344
+ var button = event.target && event.target.closest ? event.target.closest('.revoke') : null
345
+ if (!button) return
346
+ button.disabled = true
347
+ fetch('${ROUTE_PREFIX}/revoke', {
348
+ method: 'POST',
349
+ headers: { 'content-type': 'application/json' },
350
+ body: JSON.stringify({ deviceId: button.getAttribute('data-id') }),
351
+ }).then(function () { location.reload() }).catch(function () { button.disabled = false })
352
+ })
353
+ setInterval(refresh, 2000)
354
+ document.addEventListener('visibilitychange', function () {
355
+ if (!document.hidden) refresh()
356
+ })
357
+ }())
358
+ </script>
359
+ </body>
360
+ </html>`
361
+ }
362
+
363
+ /**
364
+ * Origins the phone may dial, from the environment or the network probe.
365
+ *
366
+ * `DSH_REMOTE_ORIGINS` (comma-separated) wins over `DSH_REMOTE_ORIGIN`; without either
367
+ * the gateway auto-detects a Tailscale address (works from any network) and adds the
368
+ * LAN address second (fast at home). The first origin is primary and lands in the QR.
369
+ *
370
+ * @param {number} port - gateway port.
371
+ * @returns {string[]} origins in priority order; empty when nothing is known yet.
372
+ */
373
+ function configuredOrigins(port) {
374
+ const multiple = process.env.DSH_REMOTE_ORIGINS
375
+ if (typeof multiple === 'string' && multiple.trim() !== '') {
376
+ return multiple.split(',').map((value) => value.trim()).filter(Boolean)
377
+ }
378
+ const single = process.env.DSH_REMOTE_ORIGIN
379
+ if (typeof single === 'string' && single.trim() !== '') return [single.trim()]
380
+ // «Свой сервер»: установщик кладёт публичный origin в state-каталог. Это
381
+ // переживает перезапуск DSH и не требует правки launchctl/окружения.
382
+ const fromFile = readOriginsFile()
383
+ if (fromFile.length > 0) return fromFile
384
+ return autoOrigins(port)
385
+ }
386
+
387
+ /**
388
+ * Origins, сохранённые установщиком своего сервера.
389
+ *
390
+ * @returns {string[]} origins или пустой массив.
391
+ */
392
+ function readOriginsFile() {
393
+ const file = join(homedir(), '.dsh', 'dsh-remote', 'origins.json')
394
+ try {
395
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
396
+ const values = Array.isArray(parsed?.origins) ? parsed.origins : []
397
+ return values
398
+ .filter((value) => typeof value === 'string' && /^https?:\/\//u.test(value))
399
+ .map((value) => value.trim())
400
+ } catch {
401
+ return []
402
+ }
403
+ }
404
+
405
+ /** SSH-хосты из ~/.ssh/config: alias -> HostName. */
406
+ function readSshEntries() {
407
+ const file = join(homedir(), '.ssh', 'config')
408
+ try {
409
+ const text = readFileSync(file, 'utf8')
410
+ const hosts = new Map()
411
+ let aliases = []
412
+ for (const line of text.split('\n')) {
413
+ const host = /^\s*Host\s+(.+)$/iu.exec(line)
414
+ if (host !== null) {
415
+ aliases = host[1].trim().split(/\s+/u)
416
+ .filter((value) => value !== '' && !value.startsWith('!') && !/[*?]/u.test(value))
417
+ for (const alias of aliases) if (!hosts.has(alias)) hosts.set(alias, alias)
418
+ continue
419
+ }
420
+ const hostName = /^\s*HostName\s+(.+)$/iu.exec(line)
421
+ if (hostName !== null) {
422
+ for (const alias of aliases) hosts.set(alias, hostName[1].trim())
423
+ }
424
+ }
425
+ return [...hosts].map(([alias, host]) => ({ alias, host })).slice(0, 30)
426
+ } catch {
427
+ return []
428
+ }
429
+ }
430
+
431
+ /** Публичный хост для введённого адреса или SSH-alias. */
432
+ function deriveServerAddress(input) {
433
+ const value = input.trim()
434
+ const entry = readSshEntries().find((item) => item.alias === value)
435
+ if (entry !== undefined) return { sshHost: entry.alias, origin: `https://${entry.host}:3449` }
436
+ if (/^https:\/\//iu.test(value)) {
437
+ try {
438
+ const url = new URL(value)
439
+ return { sshHost: url.hostname, origin: value.replace(/\/$/u, '') }
440
+ } catch {
441
+ return undefined
442
+ }
443
+ }
444
+ if (value.includes('://') || /[/?#\s]/u.test(value)) return undefined
445
+ if (value.includes('@')) {
446
+ const host = value.split('@').pop() ?? ''
447
+ return { sshHost: value, origin: `https://${host}:3449` }
448
+ }
449
+ return { sshHost: value.split(':')[0], origin: `https://${value.includes(':') ? value : `${value}:3449`}` }
450
+ }
451
+
452
+ /** Запущен ли LaunchAgent туннеля (если ставился setup.sh). */
453
+ function tunnelRunning() {
454
+ return new Promise((resolve) => {
455
+ if (process.platform === 'darwin') {
456
+ execFile('launchctl', ['print', `gui/${String(process.getuid())}/app.ddeck.relay`], { timeout: 2000 }, (error) => resolve(error === null))
457
+ return
458
+ }
459
+ // Linux/Windows: setup.sh запускает туннель через nohup и пишет pid.
460
+ // LaunchAgent есть только на macOS; здесь проверяем живой pid.
461
+ const pidFile = join(homedir(), '.dsh', 'dsh-remote', 'relay-tunnel.pid')
462
+ readFile(pidFile, 'utf8').then((text) => {
463
+ const pid = Number.parseInt(text.trim(), 10)
464
+ if (!Number.isInteger(pid) || pid <= 0) { resolve(false); return }
465
+ try { process.kill(pid, 0); resolve(true) } catch { resolve(false) }
466
+ }).catch(() => resolve(false))
467
+ })
468
+ }
469
+
470
+ /**
471
+ * Find an address the phone can actually reach when the gateway could not start.
472
+ *
473
+ * This is the degraded pairing page (no TLS listener): it needs a plain HTTP origin and
474
+ * only runs when the gateway failed. In the normal path `configuredOrigins` is what the
475
+ * QR carries.
476
+ *
477
+ * @param {number} port - port this DSH instance listens on.
478
+ * @returns {string | undefined} origin such as `http://192.168.1.10:3449`, or undefined.
479
+ */
480
+ function reachableOrigin(port) {
481
+ const configured = configuredOrigins(port)[0]
482
+ if (configured !== undefined) return configured
483
+ const candidates = []
484
+ for (const [name, addresses] of Object.entries(networkInterfaces())) {
485
+ if (name === 'lo0' || name.startsWith('utun') || name.startsWith('bridge')) continue
486
+ for (const address of addresses ?? []) {
487
+ if (address.family !== 'IPv4' || address.internal) continue
488
+ const [a, b] = address.address.split('.').map(Number)
489
+ const isPrivate = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
490
+ if (isPrivate) candidates.push(address.address)
491
+ }
492
+ }
493
+ return candidates.length > 0 ? `http://${candidates[0]}:${String(port)}` : undefined
494
+ }
495
+
496
+ /**
497
+ * Список сессий с компьютера.
498
+ *
499
+ * Читаем кэш проекций DSH: у каждой сессии там есть `record.identity.cwd` (рабочая
500
+ * область) и `record.rows.title.val` (заголовок). Разбирать журналы сессий не нужно —
501
+ * кэш уже содержит то, что показывает настольное приложение.
502
+ *
503
+ * @returns {Promise<Array<{ id: string, title: string, workspace: string, createdAt: number }>>}
504
+ */
505
+ async function readSessions() {
506
+ const root = join(homedir(), '.dsh', 'storages', 'session_projcache', 'sessions')
507
+ let names
508
+ try {
509
+ names = await readdir(root)
510
+ } catch {
511
+ return []
512
+ }
513
+ const sessions = []
514
+ for (const name of names) {
515
+ if (!name.endsWith('.json')) continue
516
+ try {
517
+ const parsed = JSON.parse(await readFile(join(root, name), 'utf8'))
518
+ const record = parsed?.record
519
+ const identity = record?.identity
520
+ const title = record?.rows?.title?.val
521
+ if (typeof identity?.cwd !== 'string') continue
522
+ sessions.push({
523
+ id: name.replace(/\.json$/u, ''),
524
+ title: typeof title === 'string' && title.trim() !== '' ? title.trim() : 'Без названия',
525
+ workspace: identity.cwd,
526
+ createdAt: typeof identity.createdAt === 'number' ? identity.createdAt : 0,
527
+ })
528
+ } catch {
529
+ // Битый или недописанный файл кэша — пропускаем, а не роняем список.
530
+ }
531
+ }
532
+ // Сначала названные, потом свежие безымянные.
533
+ //
534
+ // Раньше брались просто 40 самых новых файлов кэша — и в списке оказывались одни
535
+ // пустые заготовки (title = null), а настоящие сессии с названиями не попадали вовсе:
536
+ // в кэше их 269, названия есть у старых.
537
+ const named = sessions.filter((session) => session.title !== 'Без названия')
538
+ const blank = sessions.filter((session) => session.title === 'Без названия')
539
+ named.sort((a, b) => b.createdAt - a.createdAt)
540
+ blank.sort((a, b) => b.createdAt - a.createdAt)
541
+ return [...named.slice(0, 30), ...blank.slice(0, 6)]
542
+ }
543
+
544
+ /**
545
+ * Choose the language for a request.
546
+ *
547
+ * An explicit `?lang=` wins — it is the switch being used. Otherwise the first
548
+ * `Accept-Language` entry decides, and anything that is not Russian gets English:
549
+ * a wrong guess is recoverable with one tap, a missing page is not.
550
+ *
551
+ * @param {URL} url
552
+ * @param {string} headerValue
553
+ * @returns {'ru' | 'en'}
554
+ */
555
+ /**
556
+ * Escape a value for HTML.
557
+ *
558
+ * Device labels are chosen by the person and come back from the phone, and the page
559
+ * prints them. An unescaped label is a scripting hole with a friendly face.
560
+ *
561
+ * @param {string} value
562
+ * @returns {string}
563
+ */
564
+ function escapeHtml(value) {
565
+ return String(value)
566
+ .replaceAll('&', '&amp;')
567
+ .replaceAll('<', '&lt;')
568
+ .replaceAll('>', '&gt;')
569
+ .replaceAll('"', '&quot;')
570
+ .replaceAll("'", '&#39;')
571
+ }
572
+
573
+ function pickLanguage(url, headerValue) {
574
+ const chosen = url.searchParams.get('lang')
575
+ if (chosen === 'ru' || chosen === 'en') return chosen
576
+ return /^\s*ru\b/i.test(headerValue) ? 'ru' : 'en'
577
+ }
578
+
579
+ /**
580
+ * Render the QR for a pairing payload.
581
+ *
582
+ * @param {string} payload - what the phone will read.
583
+ * @returns {Promise<string>} inline SVG, or an empty string when rendering fails.
584
+ */
585
+ async function renderQr(payload) {
586
+ try {
587
+ return renderQrSvg(payload, { margin: 1, errorCorrectionLevel: 'M' })
588
+ } catch {
589
+ // The code below the QR is the fallback; a failed render must not break the page.
590
+ return ''
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Build the pairing payload the phone reads.
596
+ *
597
+ * One object, versioned, carrying everything the app needs: where the computer is and
598
+ * which window is open. No separate fields for the person to copy.
599
+ *
600
+ * @param {string} origin - origin the phone should use.
601
+ * @param {string} instanceId
602
+ * @param {string} token
603
+ * @returns {string} URI for the QR.
604
+ */
605
+ function pairingUri(origin, instanceId, token, expiresAt) {
606
+ const url = new URL('dshremote://pair')
607
+ url.searchParams.set('v', '1')
608
+ url.searchParams.set('u', origin)
609
+ url.searchParams.set('i', instanceId)
610
+ url.searchParams.set('t', token)
611
+ // A hint, not a rule: the phone can reject an old code before spending a request,
612
+ // and the server still checks the window itself because a phone's clock is not a
613
+ // trustworthy authority.
614
+ url.searchParams.set('e', String(Math.floor(expiresAt / 1000)))
615
+ return url.toString()
616
+ }
617
+
618
+
619
+ /**
620
+ * Activate the plugin.
621
+ *
622
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis context provided by DSH.
623
+ */
624
+ export function apply(ctx) {
625
+ const logger = ctx.logger('dsh-remote')
626
+ const pairing = new PairingWindow()
627
+ const statePromise = loadState()
628
+ /** Recent password attempts for the phone-to-phone pairing route. */
629
+ const mobilePairAttempts = new Map()
630
+
631
+ /** Started gateway, or undefined while it is still coming up or after a failure. */
632
+ let gateway
633
+ /** Перезапуск шлюза после смены публичного origin; назначается effect-ом ниже. */
634
+ let restartGateway
635
+
636
+ /**
637
+ * Devices that may right now open this computer.
638
+ *
639
+ * Revoked and expired entries are dropped: a list that shows dead entries teaches the
640
+ * person to ignore it.
641
+ */
642
+ const pairedDevices = () => {
643
+ if (gateway === undefined) return []
644
+ const now = Date.now()
645
+ return gateway.gateway.access.listDevices()
646
+ .filter((device) => device.revokedAt === undefined && device.expiresAt > now)
647
+ .sort((a, b) => b.lastSeenAt - a.lastSeenAt)
648
+ .map((device) => ({
649
+ id: device.id,
650
+ label: device.label,
651
+ lastSeenAt: device.lastSeenAt,
652
+ expiresAt: device.expiresAt,
653
+ }))
654
+ }
655
+
656
+ /** Read a small JSON body without trusting its size. */
657
+ const readBody = async (request) => {
658
+ const chunks = []
659
+ let bytes = 0
660
+ for await (const chunk of request) {
661
+ bytes += chunk.length
662
+ if (bytes > 8 * 1024) throw new Error('body too large')
663
+ chunks.push(chunk)
664
+ }
665
+ if (bytes === 0) return {}
666
+ try {
667
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
668
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
669
+ } catch {
670
+ return {}
671
+ }
672
+ }
673
+
674
+ /**
675
+ * The pairing window the page currently shows.
676
+ *
677
+ * The page polls every two seconds; opening a fresh window on every poll rotated the
678
+ * six-digit code out from under the person reading it and made the QR unreliable.
679
+ * One window is reused until it is close to expiry or the paired-device count
680
+ * changes (which means it was just consumed).
681
+ */
682
+ let shownPairing
683
+
684
+ /**
685
+ * What the page shows right now: a code and the QR that carries it.
686
+ *
687
+ * The gateway is asked first because only it can pair a phone — its window is the one
688
+ * `native-pair` accepts. Without a gateway the page falls back to a local window and
689
+ * says so, which is more useful than an empty screen: the person learns that pairing
690
+ * is unavailable instead of pointing a camera at nothing.
691
+ */
692
+ const currentPairing = async (state, options = {}) => {
693
+ // Явный запрос «новый код»: инвалидируем кэш и открываем новое окно, иначе
694
+ // панель «Обновить» показывала тот же QR до пяти минут.
695
+ if (options.refresh === true) shownPairing = undefined
696
+ // Устройства отдаём вместе с кодом: панель на телефоне показывает список подключённых
697
+ // рядом с QR. Раньше их здесь не было, и панель всегда писала «Пока ни одного
698
+ // устройства», хотя телефон уже был подключён (в /status он есть).
699
+ const devices = pairedDevices()
700
+ if (gateway !== undefined) {
701
+ const now = Date.now()
702
+ const stale = shownPairing === undefined
703
+ || shownPairing.gateway !== gateway
704
+ || shownPairing.expiresAt - now <= 5_000
705
+ || shownPairing.deviceCount !== devices.length
706
+ || gateway.gateway.access.hasPairingWindow?.() === false
707
+ if (stale) {
708
+ const opened = await openReadablePairing(gateway, PAIRING_TTL_MS)
709
+ shownPairing = {
710
+ gateway,
711
+ code: opened.code,
712
+ expiresAt: opened.expiresAt,
713
+ pairUrl: opened.pairUrl,
714
+ deviceCount: devices.length,
715
+ }
716
+ }
717
+ return {
718
+ code: shownPairing.code,
719
+ expiresAt: shownPairing.expiresAt,
720
+ pairUri: shownPairing.pairUrl,
721
+ origin: gateway.origin,
722
+ origins: gateway.origins,
723
+ gateway: true,
724
+ // CA fingerprint from the invitation; the app pins it instead of trusting the
725
+ // first certificate it sees (TOFU) when it pairs from a link or an intent.
726
+ instance: gateway.config.instanceId,
727
+ devices,
728
+ }
729
+ }
730
+ const window = pairing.open()
731
+ const origin = reachableOrigin(ctx.webServer.port) ?? `http://127.0.0.1:${String(ctx.webServer.port)}`
732
+ return {
733
+ code: window.code,
734
+ expiresAt: window.expiresAt,
735
+ pairUri: pairingUri(origin, state.instanceId, window.token, window.expiresAt),
736
+ origin,
737
+ origins: [origin],
738
+ gateway: false,
739
+ instance: state.instanceId,
740
+ devices,
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Origin the phone should use: the address the person reached DSH on.
746
+ *
747
+ * Behind a proxy the scheme comes from `x-forwarded-proto`; otherwise the Host the
748
+ * request arrived with is the truth, because that is the address the phone will use
749
+ * too.
750
+ */
751
+ const originOf = (request) => {
752
+ const host = request.headers.host
753
+ const forwarded = request.headers['x-forwarded-proto']
754
+ const scheme = typeof forwarded === 'string' && forwarded.startsWith('https') ? 'https' : 'http'
755
+ return typeof host === 'string' && host !== '' ? `${scheme}://${host}` : `http://127.0.0.1:${ctx.webServer.port}`
756
+ }
757
+
758
+ // `ctx.effect` owns the registration: DSH may disable or reload the plugin at any
759
+ // time, and the routes have to disappear with it rather than answering from a dead
760
+ // plugin.
761
+ ctx.effect(() => ctx.webServer.register({
762
+ kind: 'prefix',
763
+ path: ROUTE_PREFIX,
764
+ handler: async (request, response) => {
765
+ const state = await statePromise
766
+ const url = new URL(request.url ?? '/', 'http://localhost')
767
+
768
+ // Сопряжение второго телефона с уже подключённого телефона.
769
+ //
770
+ // Это не админ-маршрут: его вызывает мобильная страница. Доступ закрыт
771
+ // паролем, который задаёт владелец на компьютере; без настроенного пароля
772
+ // маршрут отвечает 403 и окно сопряжения не открывает.
773
+ if (url.pathname === `${ROUTE_PREFIX}/mobile-pair` && request.method === 'GET') {
774
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
775
+ response.end(JSON.stringify({ available: true, pinSet: (await readMobilePin()) !== undefined }))
776
+ return
777
+ }
778
+
779
+ if (url.pathname === `${ROUTE_PREFIX}/mobile-pair` && request.method === 'POST') {
780
+ if (mobilePairAttempts.size > 512) mobilePairAttempts.clear()
781
+ const clientKey = String(request.socket?.remoteAddress ?? 'unknown')
782
+ const attempts = mobilePairAttempts.get(clientKey) ?? { at: 0, count: 0 }
783
+ if (Date.now() - attempts.at > 5 * 60_000) { attempts.at = Date.now(); attempts.count = 0 }
784
+ attempts.count += 1
785
+ mobilePairAttempts.set(clientKey, attempts)
786
+ const pin = await readMobilePin()
787
+ const body = await readBody(request)
788
+ const password = typeof body.password === 'string' ? body.password : ''
789
+ if (pin === undefined) {
790
+ response.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
791
+ response.end(JSON.stringify({ error: 'pin_not_set' }))
792
+ return
793
+ }
794
+ if (attempts.count > 8 || password.length === 0 || password.length > 128 || !verifyMobilePin(password, pin)) {
795
+ response.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
796
+ response.end(JSON.stringify({ error: 'invalid_password' }))
797
+ return
798
+ }
799
+ try {
800
+ const current = await currentPairing(state, { refresh: true })
801
+ const qrSvg = await renderQrSvg(current.pairUri)
802
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
803
+ response.end(JSON.stringify({ ok: true, code: current.code, expiresAt: current.expiresAt, pairUri: current.pairUri, qrSvg, origin: current.origin, instance: current.instance }))
804
+ } catch (error) {
805
+ response.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
806
+ response.end(JSON.stringify({ error: 'pairing_failed', message: error instanceof Error ? error.message : String(error) }))
807
+ }
808
+ return
809
+ }
810
+
811
+ if (!isTrustedAdminRequest(request)) {
812
+ // No QR, no code, no hint of one: this address is for the person at the machine.
813
+ response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
814
+ response.end('dsh-remote: open this page on the computer itself\n')
815
+ return
816
+ }
817
+
818
+ if (url.pathname === `${ROUTE_PREFIX}/mobile-pin`) {
819
+ if (request.method === 'POST') {
820
+ const body = await readBody(request)
821
+ const password = typeof body.password === 'string' ? body.password : ''
822
+ if (password.length < 6 || password.length > 64) {
823
+ response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
824
+ response.end(JSON.stringify({ error: 'invalid_password', message: 'пароль должен быть от 6 до 64 символов' }))
825
+ return
826
+ }
827
+ const salt = randomBytes(16).toString('hex')
828
+ const hash = scryptSync(password, Buffer.from(salt, 'hex'), 32).toString('hex')
829
+ await writeFile(mobilePinPath(), `${JSON.stringify({ salt, hash }, null, 2)}\n`, { mode: 0o600 })
830
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
831
+ response.end(JSON.stringify({ set: true }))
832
+ return
833
+ }
834
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
835
+ response.end(JSON.stringify({ set: (await readMobilePin()) !== undefined }))
836
+ return
837
+ }
838
+
839
+ if (url.pathname === `${ROUTE_PREFIX}/pairing`) {
840
+ let current
841
+ try {
842
+ // «Новый код» — только POST: GET-ротация позволяет чужой странице
843
+ // вращать окно сопряжения через CSRF/DNS-rebinding.
844
+ const method = (request.method ?? 'GET').toUpperCase()
845
+ current = await currentPairing(state, { refresh: method === 'POST' })
846
+ } catch (error) {
847
+ // A failure here is the difference between a working phone and a blank screen;
848
+ // say what happened instead of letting the server answer 400 with no body.
849
+ response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
850
+ response.end(JSON.stringify({ error: 'pairing_failed', message: error instanceof Error ? error.message : String(error) }))
851
+ return
852
+ }
853
+ const qrSvg = await renderQr(current.pairUri)
854
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
855
+ // The URI travels with the image on purpose: the app can be handed either one,
856
+ // and a test can assert what the QR holds without decoding a picture.
857
+ response.end(JSON.stringify({ ...current, qrSvg }))
858
+ return
859
+ }
860
+
861
+ if (url.pathname === `${ROUTE_PREFIX}/sessions`) {
862
+ // Свой список сессий: настольный клиент на нашей странице переходы не выполняет,
863
+ // поэтому список и открытие делаем сами. Заголовки берём из кэша проекций DSH —
864
+ // это те же данные, что видит приложение на компьютере.
865
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
866
+ response.end(JSON.stringify({ sessions: await readSessions() }))
867
+ return
868
+ }
869
+
870
+ if (url.pathname === `${ROUTE_PREFIX}/devices`) {
871
+ // Только список сопряжённых устройств: инструменты гигиены не должны
872
+ // открывать новое окно сопряжения ради чтения списка.
873
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
874
+ response.end(JSON.stringify({ devices: pairedDevices() }))
875
+ return
876
+ }
877
+
878
+ if (url.pathname === `${ROUTE_PREFIX}/status`) {
879
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
880
+ response.end(JSON.stringify({
881
+ plugin: 'dsh-remote',
882
+ version: PLUGIN_VERSION,
883
+ instanceId: gateway?.config.instanceId ?? state.instanceId,
884
+ origin: gateway?.origin,
885
+ gateway: gateway !== undefined,
886
+ // Four is what fits above the fold on a laptop panel without pushing the QR out
887
+ // of sight; the count line still says how many there are in total.
888
+ devices: pairedDevices().slice(0, 4),
889
+ }))
890
+ return
891
+ }
892
+
893
+ // Disconnecting a phone is an ordinary action, not a corner case: phones get lost,
894
+ // sold and replaced, and the person needs a way to take access away.
895
+ if (url.pathname === `${ROUTE_PREFIX}/revoke` && request.method === 'POST') {
896
+ const body = await readBody(request)
897
+ const deviceId = typeof body.deviceId === 'string' ? body.deviceId : ''
898
+ if (gateway === undefined || !/^[a-f\d]{32}$/u.test(deviceId)) {
899
+ response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
900
+ response.end(JSON.stringify({ error: 'invalid_request' }))
901
+ return
902
+ }
903
+ const removed = await gateway.gateway.access.revokeDevice(deviceId)
904
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
905
+ response.end(JSON.stringify({ removed }))
906
+ return
907
+ }
908
+
909
+ if (url.pathname === `${ROUTE_PREFIX}/server` && (request.method ?? 'GET').toUpperCase() === 'GET') {
910
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
911
+ response.end(JSON.stringify({
912
+ origin: gateway?.origin,
913
+ origins: gateway?.origins ?? [],
914
+ sshHosts: readSshEntries(),
915
+ tunnel: await tunnelRunning(),
916
+ }))
917
+ return
918
+ }
919
+
920
+ if (url.pathname === `${ROUTE_PREFIX}/server` && request.method === 'POST') {
921
+ const body = await readBody(request)
922
+ const address = typeof body.address === 'string' ? body.address.trim() : ''
923
+ const suppliedOrigin = typeof body.origin === 'string' ? body.origin.trim().replace(/\/$/u, '') : ''
924
+ let suppliedSsh = typeof body.sshHost === 'string' ? body.sshHost.trim() : ''
925
+ let origin = suppliedOrigin
926
+ if (origin === '' && address !== '') {
927
+ const derived = deriveServerAddress(address)
928
+ if (derived === undefined) {
929
+ response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
930
+ response.end(JSON.stringify({ error: 'invalid_address', message: 'укажите hostname, SSH-alias, user@host или https://host:port' }))
931
+ return
932
+ }
933
+ origin = derived.origin
934
+ suppliedSsh = suppliedSsh || derived.sshHost
935
+ }
936
+ let parsed
937
+ try {
938
+ parsed = new URL(origin)
939
+ } catch {
940
+ parsed = undefined
941
+ }
942
+ if (parsed === undefined || parsed.protocol !== 'https:' || parsed.pathname !== '/' || parsed.search !== '' || parsed.username !== '' || parsed.password !== '') {
943
+ response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
944
+ response.end(JSON.stringify({ error: 'invalid_origin', message: 'origin must be https://host[:port] without path' }))
945
+ return
946
+ }
947
+ if (suppliedSsh !== '' && (!/^[\w.@:-]+$/u.test(suppliedSsh) || suppliedSsh.startsWith('-'))) {
948
+ response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
949
+ response.end(JSON.stringify({ error: 'invalid_ssh_host' }))
950
+ return
951
+ }
952
+ try {
953
+ let output = ''
954
+ let steps = []
955
+ if (suppliedSsh !== '') {
956
+ steps.push('SSH-соединение и установка relay')
957
+ const script = new URL('../relay/setup.sh', import.meta.url)
958
+ output = await new Promise((resolve, reject) => {
959
+ const child = spawn(script.pathname, ['--ssh', suppliedSsh, '--origin', origin, '--gateway-origin', origin], {
960
+ cwd: dirname(script.pathname),
961
+ stdio: ['ignore', 'pipe', 'pipe'],
962
+ })
963
+ let text = ''
964
+ const append = (chunk) => { text = `${text}${String(chunk)}`.slice(-8_000) }
965
+ child.stdout.on('data', append)
966
+ child.stderr.on('data', append)
967
+ const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('relay setup timed out')) }, 120_000)
968
+ child.on('error', (error) => { clearTimeout(timer); reject(error) })
969
+ child.on('close', (code) => {
970
+ clearTimeout(timer)
971
+ if (code === 0) resolve(text.trim())
972
+ else reject(new Error(text.trim() || `relay setup exited ${String(code)}`))
973
+ })
974
+ })
975
+ steps.push('Туннель запущен')
976
+ } else {
977
+ steps.push('Сервер уже настроен: сохраняю адрес')
978
+ const file = join(homedir(), '.dsh', 'dsh-remote', 'origins.json')
979
+ await mkdir(dirname(file), { recursive: true })
980
+ await writeFile(file, `${JSON.stringify({ origins: [origin] }, null, 2)}\n`, 'utf8')
981
+ }
982
+ if (restartGateway === undefined) throw new Error('gateway is not ready; restart DSH and try again')
983
+ steps.push('Обновляю QR и сертификат')
984
+ await restartGateway([origin])
985
+ steps.push('Готово')
986
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
987
+ response.end(JSON.stringify({ ok: true, origin, output, steps }))
988
+ } catch (error) {
989
+ response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
990
+ response.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }))
991
+ }
992
+ return
993
+ }
994
+
995
+ // An address under our prefix that we did not define is a mistake, not a page:
996
+ // answering with HTML here would hide a typo behind a working-looking screen.
997
+ if (url.pathname.startsWith(`${ROUTE_PREFIX}/`)) {
998
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
999
+ response.end('dsh-remote: not found\n')
1000
+ return
1001
+ }
1002
+
1003
+ const language = pickLanguage(url, String(request.headers['accept-language'] ?? ''))
1004
+ const current = await currentPairing(state)
1005
+ const view = {
1006
+ pairing: { code: current.code },
1007
+ origin: current.origin,
1008
+ altOrigins: (current.origins ?? []).filter((value) => value !== current.origin),
1009
+ gateway: current.gateway,
1010
+ instanceId: gateway?.config.instanceId ?? state.instanceId,
1011
+ // Four is what fits above the fold on a laptop panel without pushing the QR out
1012
+ // of sight; the count line still says how many there are in total.
1013
+ devices: pairedDevices().slice(0, 4),
1014
+ qrSvg: await renderQr(current.pairUri),
1015
+ }
1016
+ response.writeHead(200, {
1017
+ 'content-type': 'text/html; charset=utf-8',
1018
+ 'content-language': language,
1019
+ 'cache-control': 'no-store',
1020
+ })
1021
+ response.end(entryPage(language, view))
1022
+ },
1023
+ }), 'dsh-remote: phone entry page')
1024
+
1025
+ ctx.effect(() => {
1026
+ let handle
1027
+ let closed = false
1028
+ const upstreamOrigin = `http://127.0.0.1:${String(ctx.webServer.port)}`
1029
+ // The harness authenticates its browser client with a one-time launch token. The
1030
+ // gateway needs that same credential to proxy on the phone's behalf.
1031
+ let upstreamLoginUrl
1032
+ try {
1033
+ const authenticated = ctx.connection?.authenticatedUrl?.(upstreamOrigin)
1034
+ if (typeof authenticated === 'string' && new URL(authenticated).search !== '') {
1035
+ upstreamLoginUrl = authenticated
1036
+ }
1037
+ } catch (error) {
1038
+ logger.warn('connection.authenticatedUrl failed: %s', error instanceof Error ? error.message : String(error))
1039
+ }
1040
+ if (upstreamLoginUrl === undefined) {
1041
+ logger.warn('no upstream login URL: proxied requests will likely fail')
1042
+ }
1043
+ const port = Number(process.env.DSH_REMOTE_PORT ?? '3449')
1044
+
1045
+ const start = async (origins) => {
1046
+ const started = await startGateway({
1047
+ upstreamPort: ctx.webServer.port,
1048
+ upstreamLoginUrl,
1049
+ stateDirectory: defaultStateDirectory(),
1050
+ port,
1051
+ ...(origins.length > 0 ? { origins } : {}),
1052
+ onWarning: (message) => { logger.warn('%s', message) },
1053
+ })
1054
+ if (closed) {
1055
+ await started.close()
1056
+ return undefined
1057
+ }
1058
+ handle = started
1059
+ gateway = started
1060
+ logger.info('gateway listening on %s', started.origin)
1061
+ return started
1062
+ }
1063
+
1064
+ restartGateway = async (origins) => {
1065
+ if (closed) throw new Error('gateway is closed')
1066
+ const old = handle
1067
+ handle = undefined
1068
+ if (gateway === old) gateway = undefined
1069
+ await old?.close()
1070
+ return start(origins)
1071
+ }
1072
+
1073
+ void start(configuredOrigins(port)).catch((error) => {
1074
+ // A gateway that cannot start must not take the page down with it: the page still
1075
+ // shows a code and says what is wrong, and the person can act on that.
1076
+ logger.error('gateway failed: %s', error instanceof Error ? error.message : String(error))
1077
+ })
1078
+
1079
+ return () => {
1080
+ closed = true
1081
+ restartGateway = undefined
1082
+ if (gateway === handle) gateway = undefined
1083
+ void handle?.close()
1084
+ }
1085
+ }, 'dsh-remote: gateway')
1086
+
1087
+ logger.info('dsh-remote ready: %s (pairing window %ds)', ROUTE_PREFIX, WINDOW_MS / 1000)
1088
+ }