@goodandready/dsh-lanmode 0.3.1 → 0.5.0

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/health.js ADDED
@@ -0,0 +1,104 @@
1
+ // Страница диагностики.
2
+ //
3
+ // Каждый разбор поломки по сети начинался одинаково: непонятно, какой режим,
4
+ // что подменено, видит ли браузер защищённое соединение и почему молчит
5
+ // микрофон. Это выяснялось руками и подолгу.
6
+ //
7
+ // Половину ответов знает хост, половину — только браузер, потому что защищённое
8
+ // соединение и наличие микрофона существуют исключительно на его стороне.
9
+ // Поэтому страница отдаёт и то и другое: серверное как есть, браузерное —
10
+ // скриптом, который выполняется у открывшего.
11
+ //
12
+ // Ничего секретного здесь быть не должно: страница открыта всем, кто дотянулся
13
+ // до харнесса.
14
+
15
+ /** Данные, которые знает хост. */
16
+ export function hostReport(state) {
17
+ return {
18
+ version: state.version,
19
+ mode: state.mode,
20
+ modeReason: state.modeReason ?? '',
21
+ listener: state.listener ?? null,
22
+ tls: state.tls ?? { enabled: false },
23
+ pieces: state.pieces,
24
+ allow: state.allow ?? [],
25
+ assumptions: state.assumptions ?? [],
26
+ }
27
+ }
28
+
29
+ const BROWSER_SCRIPT = `
30
+ (function () {
31
+ var out = document.getElementById('browser')
32
+ var shim = window.__DSH_LANMODE__ || null
33
+ var checks = [
34
+ ['защищённое соединение', window.isSecureContext === true,
35
+ 'без него нет ни микрофона, ни crypto.randomUUID, ни буфера обмена. Лечится HTTPS: настройка tls в прямом режиме или прокси с сертификатом'],
36
+ ['микрофон доступен браузеру', !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
37
+ 'единственное, чего не лечит подмена: за ним настоящее устройство. Нужен именно HTTPS'],
38
+ ['crypto.randomUUID', typeof (window.crypto && window.crypto.randomUUID) === 'function',
39
+ 'интерфейс зовёт его при загрузке; на голом HTTP его подставляет заплатка'],
40
+ ['буфер обмена', !!(navigator.clipboard && navigator.clipboard.writeText),
41
+ 'кнопки «копировать»; на голом HTTP его подставляет заплатка'],
42
+ ['заплатка отработала', !!shim,
43
+ 'если нет — скрипт не попал на страницу или его выключили через ?lanmode=off'],
44
+ ['страница считается своей', /^(localhost|\\[::1\\]|::1|127\\.)/.test(location.hostname),
45
+ 'на чужом имени харнесс уводит настройки в режим памяти; это и чинит заплатка']
46
+ ]
47
+ var rows = checks.map(function (item) {
48
+ return '<tr><td>' + (item[1] ? '✔' : '✘') + '</td><td>' + item[0] + '</td><td>'
49
+ + (item[1] ? '' : item[2]) + '</td></tr>'
50
+ }).join('')
51
+ out.innerHTML = '<table>' + rows + '</table>'
52
+ window.__DSH_LANMODE_BROWSER__ = checks.map(function (item) {
53
+ return { name: item[0], ok: item[1] }
54
+ })
55
+ })()
56
+ `
57
+
58
+ function row(item) {
59
+ return '<tr><td>' + (item.ok ? '✔' : '✘') + '</td><td>' + escapeHtml(item.name)
60
+ + '</td><td>' + (item.ok ? '' : escapeHtml(item.detail || '')) + '</td></tr>'
61
+ }
62
+
63
+ function escapeHtml(text) {
64
+ return String(text)
65
+ .replace(/&/g, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ }
69
+
70
+ /** Страница целиком. */
71
+ export function healthPage(state) {
72
+ const report = hostReport(state)
73
+ const listener = report.listener
74
+ ? escapeHtml(report.listener.scheme + '://' + report.listener.host + ':' + report.listener.port)
75
+ : 'нет — сеть обслуживает кто-то другой'
76
+
77
+ return '<!doctype html><html lang="ru"><head><meta charset="utf-8">'
78
+ + '<meta name="viewport" content="width=device-width, initial-scale=1">'
79
+ + '<title>dsh-lanmode</title><style>'
80
+ + 'body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;max-width:900px}'
81
+ + 'h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:24px 0 8px}'
82
+ + 'table{border-collapse:collapse;width:100%}'
83
+ + 'td{padding:4px 8px;border-top:1px solid rgba(128,128,128,.3);vertical-align:top}'
84
+ + 'td:first-child{width:1.5em;text-align:center}td:nth-child(2){width:16em}'
85
+ + 'td:nth-child(3){color:#888}dl{margin:0}dt{color:#888;font-size:12px;margin-top:8px}'
86
+ + '@media(prefers-color-scheme:dark){body{background:#111;color:#ddd}}'
87
+ + '</style></head><body>'
88
+ + '<h1>dsh-lanmode ' + escapeHtml(report.version) + '</h1>'
89
+ + '<dl><dt>режим</dt><dd>' + escapeHtml(report.mode)
90
+ + (report.modeReason ? ' — ' + escapeHtml(report.modeReason) : '') + '</dd>'
91
+ + '<dt>слушатель</dt><dd>' + listener + '</dd>'
92
+ + '<dt>сертификат</dt><dd>' + (report.tls.enabled
93
+ ? escapeHtml(report.tls.source + ', отпечаток ' + report.tls.fingerprint)
94
+ : 'нет') + '</dd>'
95
+ + '<dt>подменено</dt><dd>' + escapeHtml(Object.entries(report.pieces)
96
+ .filter(([, on]) => on).map(([name]) => name).join(', ') || 'ничего') + '</dd>'
97
+ + '<dt>пускаю</dt><dd>' + escapeHtml(report.allow.length ? report.allow.join(', ') : 'всех') + '</dd>'
98
+ + '</dl>'
99
+ + '<h2>Точки крепления</h2><table>' + report.assumptions.map(row).join('') + '</table>'
100
+ + '<h2>Что видит браузер</h2><div id="browser"></div>'
101
+ + '<h2>Для отчёта об ошибке</h2><p>Те же данные в JSON: '
102
+ + '<a href="?format=json">?format=json</a></p>'
103
+ + '<script>' + BROWSER_SCRIPT + '</script></body></html>'
104
+ }
package/lib/index.js CHANGED
@@ -32,7 +32,13 @@ import z from '@deepseek-ai/schemastery'
32
32
  import { readFileSync } from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
+
36
+ import { parseAllow } from './access.js'
37
+ import { checkAssumptions, summarize } from './assumptions.js'
35
38
  import { startDirectBridge } from './bridge.js'
39
+ import { healthPage, hostReport } from './health.js'
40
+ import { detectMode } from './mode.js'
41
+ import { ensureCertificate, localAddresses, readCertificate } from './tls.js'
36
42
 
37
43
  export const name = 'dsh-lanmode'
38
44
  export const inject = ['webServer']
@@ -44,7 +50,10 @@ export const Config = z.object({
44
50
  + '"proxy": something in front of it already listens on the network (nginx and friends) — '
45
51
  + 'the plugin only repairs the page. '
46
52
  + '"direct": the plugin also opens a listener of its own on the network and forwards to the '
47
- + 'harness, so nothing else is needed.')
53
+ + 'harness, so nothing else is needed. '
54
+ + '"auto": look whether anything already answers on this machine\'s network address at the '
55
+ + 'harness port, and pick proxy if something does. When it cannot tell, it picks proxy: '
56
+ + 'an unnecessary listener on a network address is an open door, and one is not opened on a guess.')
48
57
  .default('proxy'),
49
58
  directHost: z
50
59
  .string()
@@ -69,6 +78,40 @@ export const Config = z.object({
69
78
  .description('Provide a fallback for navigator.clipboard.writeText on plain HTTP, '
70
79
  + 'so the copy buttons keep working. A no-op where the real one exists.')
71
80
  .default(true),
81
+ tls: z
82
+ .string()
83
+ .description('mode=direct: how the listener is secured. "off" — plain HTTP, as before. '
84
+ + '"self-signed" — the plugin issues a certificate itself (needs openssl) and renews it. '
85
+ + '"files" — use tlsCert and tlsKey. '
86
+ + 'This is what the microphone hangs on: a browser hands out navigator.mediaDevices only over '
87
+ + 'a secure connection, and no page-side substitution can help — there is a real device behind it.')
88
+ .default('off'),
89
+ tlsDir: z
90
+ .string()
91
+ .description('tls=self-signed: where the issued certificate is kept. '
92
+ + 'Empty uses a folder next to the harness data.')
93
+ .default(''),
94
+ tlsHosts: z
95
+ .array(z.string())
96
+ .description('tls=self-signed: extra names and addresses to put into the certificate, '
97
+ + 'on top of this machine\'s own. A certificate issued for one name is refused for every other, '
98
+ + 'even after it has been accepted once.')
99
+ .default([]),
100
+ tlsCert: z.string().description('tls=files: path to the certificate in PEM.').default(''),
101
+ tlsKey: z.string().description('tls=files: path to the private key in PEM.').default(''),
102
+ allow: z
103
+ .array(z.string())
104
+ .description('mode=direct: who may connect — addresses and CIDR ranges. Empty means everyone, '
105
+ + 'as before. This is not a password: whoever is on the list gets in unchecked. It narrows the '
106
+ + 'circle, nothing more. Behind a reverse proxy it is meaningless — every request arrives from '
107
+ + 'the proxy.')
108
+ .default([]),
109
+ diagnostics: z
110
+ .boolean()
111
+ .description('Serve GET /dsh-lanmode/health: what is patched, which mode is on, and what the '
112
+ + 'browser actually sees. Nothing secret is on that page — it is open to anyone who reached '
113
+ + 'the harness.')
114
+ .default(true),
72
115
  })
73
116
 
74
117
  const here = path.dirname(fileURLToPath(import.meta.url))
@@ -81,9 +124,36 @@ function shimSource() {
81
124
  /** Namespace, который плагин объявляет: через него режим правится настройками. */
82
125
  const NS = 'dsh-lanmode'
83
126
 
127
+ /** Версия из манифеста: она нужна странице диагностики и отчётам об ошибках. */
128
+ function version() {
129
+ try {
130
+ return JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version
131
+ } catch (unreadable) {
132
+ return 'неизвестна'
133
+ }
134
+ }
135
+
136
+ const say = (message) => {
137
+ // eslint-disable-next-line no-console
138
+ console.info('[dsh-lanmode] ' + message)
139
+ }
140
+
84
141
  export function apply(ctx, config) {
85
- // Значения берём из сервиса настроек, если он есть: тогда режим правится
86
- // в настройках, а не только в дереве плагинов. Смена режима требует
142
+ // Запуск ровно один. Раньше их было два: путь со службой настроек и путь без
143
+ // неё срабатывали оба, потому что в момент проверки службы ещё не было. С
144
+ // одной только вставкой заплатки это сходило с рук — она защищена от повтора.
145
+ // С появлением слушателя, списка адресов и страницы диагностики перестало:
146
+ // страница показывала одно состояние, а мост работал по другому.
147
+ let started = false
148
+
149
+ const once = (usingCtx, effective) => {
150
+ if (started) return
151
+ started = true
152
+ start(usingCtx, effective)
153
+ }
154
+
155
+ // Значения берём из службы настроек, если она есть: тогда режим правится в
156
+ // настройках, а не только в дереве плагинов. Смена режима требует
87
157
  // перезапуска — слушатель поднимается один раз при старте.
88
158
  ctx.inject(['settings'], (sctx) => {
89
159
  let effective = config
@@ -93,34 +163,159 @@ export function apply(ctx, config) {
93
163
  } catch (alreadyRegistered) {
94
164
  effective = config
95
165
  }
96
- start(sctx, effective)
166
+ once(sctx, effective)
97
167
  })
98
168
 
99
- // Без сервиса настроек тоже должно работать — тогда только дерево плагинов.
169
+ // Без службы настроек тоже должно работать — тогда только дерево плагинов.
170
+ // Ждём: службы может не быть вовсе, а может не быть ещё. Различить это можно
171
+ // только временем, и лучше подождать, чем запуститься не тем набором.
100
172
  ctx.effect(() => {
101
- if (ctx.get && ctx.get('settings')) return () => {}
102
- start(ctx, config)
103
- return () => {}
104
- }, 'dsh-lanmode: запуск без сервиса настроек')
173
+ const timer = setTimeout(() => once(ctx, config), 2000)
174
+ return () => clearTimeout(timer)
175
+ }, 'dsh-lanmode: запуск без службы настроек')
105
176
  }
106
177
 
107
- function start(ctx, config) {
108
- // Прямой режим: свой слушатель на сетевом адресе. В режиме прокси не
109
- // поднимаем ничего — сеть уже обслуживает кто-то другой.
110
- if (config.mode === 'direct') {
111
- ctx.effect(() => startDirectBridge(ctx, {
112
- host: config.directHost || '0.0.0.0',
113
- port: config.directPort || 3088,
114
- // eslint-disable-next-line no-console
115
- log: (message) => console.info('[dsh-lanmode] ' + message),
116
- }), 'dsh-lanmode: слушатель прямого режима')
178
+ /**
179
+ * Куда складывать выпущенный сертификат.
180
+ *
181
+ * Рядом с прочими данными харнесса, если он сказал где; иначе — куда указал
182
+ * человек. Своего пути плагин не выдумывает: писать в неизвестное место чужой
183
+ * машины он не вправе.
184
+ */
185
+ function certificateDir(config) {
186
+ if (config.tlsDir) return config.tlsDir
187
+ const home = process.env.DSH_HOME
188
+ return home ? path.join(home, 'dsh-lanmode') : ''
189
+ }
190
+
191
+ /** Поднять слушатель прямого режима: сначала сертификат, потом мост. */
192
+ async function raiseListener(ctx, config, state) {
193
+ const host = config.directHost || '0.0.0.0'
194
+ const port = config.directPort || 3088
195
+ const { rules, dropped } = parseAllow(config.allow)
196
+ if (dropped.length) say('в списке разрешённых не разобраны записи: ' + dropped.join(', '))
197
+
198
+ let tls = null
199
+ if (config.tls === 'files') {
200
+ try {
201
+ tls = readCertificate(config.tlsCert, config.tlsKey)
202
+ state.tls = { enabled: true, source: 'свой сертификат', fingerprint: tls.fingerprint }
203
+ } catch (unreadable) {
204
+ say('сертификат не прочитан (' + String(unreadable.message || unreadable)
205
+ + ') — поднимаю без защищённого соединения, микрофон работать не будет')
206
+ }
207
+ } else if (config.tls === 'self-signed') {
208
+ const dir = certificateDir(config)
209
+ if (!dir) {
210
+ say('некуда положить сертификат: задайте tlsDir — поднимаю без защищённого соединения')
211
+ } else {
212
+ try {
213
+ const hosts = [...new Set([...(config.tlsHosts ?? []), ...localAddresses()])]
214
+ const made = await ensureCertificate({ dir, hosts, log: say })
215
+ tls = made
216
+ state.tls = { enabled: true, source: 'самоподписанный', fingerprint: made.fingerprint }
217
+ say((made.issued ? 'выпущен' : 'взят') + ' сертификат на ' + hosts.length
218
+ + ' имён, отпечаток ' + made.fingerprint)
219
+ say('браузер предупредит о нём один раз: сверьте отпечаток и подтвердите')
220
+ } catch (failed) {
221
+ say('сертификат не выпущен (' + String(failed.message || failed)
222
+ + '). Обычно это значит, что нет openssl: поставьте его или задайте свой '
223
+ + 'сертификат через tls=files. Поднимаю без защищённого соединения, '
224
+ + 'микрофон работать не будет')
225
+ }
226
+ }
117
227
  }
118
228
 
229
+ state.listener = { scheme: tls ? 'https' : 'http', host, port }
230
+ return startDirectBridge(ctx, { host, port, log: say, allow: rules, tls })
231
+ }
232
+
233
+ function start(ctx, config) {
119
234
  const pieces = {
120
235
  settings: config.settings !== false,
121
236
  randomUuid: config.randomUuid !== false,
122
237
  clipboard: config.clipboard !== false,
123
238
  }
239
+
240
+ const state = {
241
+ version: version(),
242
+ mode: config.mode,
243
+ modeReason: '',
244
+ listener: null,
245
+ tls: { enabled: false },
246
+ pieces,
247
+ allow: Array.isArray(config.allow) ? config.allow : [],
248
+ assumptions: [],
249
+ }
250
+
251
+ // Режим: заданный руками — как сказано, `auto` — по тому, обслуживает ли сеть
252
+ // кто-то другой. Слушатель поднимается один раз, поэтому и решение одно.
253
+ ctx.effect(() => {
254
+ let stop = () => {}
255
+ let alive = true
256
+
257
+ const decide = async () => {
258
+ let mode = config.mode
259
+ if (mode === 'auto') {
260
+ const verdict = await detectMode({
261
+ addresses: localAddresses(),
262
+ port: ctx.webServer && ctx.webServer.port,
263
+ directPort: config.directPort || 3088,
264
+ })
265
+ mode = verdict.mode
266
+ state.mode = mode
267
+ state.modeReason = verdict.reason
268
+ say('режим выбран сам: ' + mode + ' — ' + verdict.reason)
269
+ }
270
+ if (!alive || mode !== 'direct') return
271
+ stop = await raiseListener(ctx, config, state)
272
+ }
273
+
274
+ decide().catch((failed) => say('прямой режим не поднялся: ' + String(failed.message || failed)))
275
+ return () => { alive = false; stop() }
276
+ }, 'dsh-lanmode: слушатель прямого режима')
277
+
278
+ // Проверка точек крепления: плагин чинит чужое поведение, и его собственная
279
+ // поломка обязана быть заметной.
280
+ ctx.effect(() => {
281
+ let alive = true
282
+ const port = ctx.webServer && ctx.webServer.port
283
+ const fetchIndex = () => fetch('http://127.0.0.1:' + port + '/').then((answer) => answer.text())
284
+
285
+ // Даём харнессу договорить о себе: страница отдаётся не в первый миг.
286
+ const timer = setTimeout(() => {
287
+ checkAssumptions({ webServer: ctx.webServer, fetchIndex })
288
+ .then((results) => {
289
+ if (!alive) return
290
+ state.assumptions = results
291
+ const line = summarize(results)
292
+ if (results.every((item) => item.ok)) say(line)
293
+ // eslint-disable-next-line no-console
294
+ else console.warn('[dsh-lanmode] ' + line)
295
+ })
296
+ .catch((failed) => say('проверить точки крепления не вышло: ' + String(failed.message || failed)))
297
+ }, 3000)
298
+
299
+ return () => { alive = false; clearTimeout(timer) }
300
+ }, 'dsh-lanmode: проверка точек крепления')
301
+
302
+ if (config.diagnostics !== false) {
303
+ ctx.effect(() => ctx.webServer.register({
304
+ kind: 'exact',
305
+ path: '/dsh-lanmode/health',
306
+ handler: (req, res) => {
307
+ const json = String(req.url ?? '').includes('format=json')
308
+ if (json) {
309
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
310
+ res.end(JSON.stringify(hostReport(state), null, 2))
311
+ return
312
+ }
313
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
314
+ res.end(healthPage(state))
315
+ },
316
+ }), 'dsh-lanmode: страница диагностики')
317
+ }
318
+
124
319
  // Ничего не включено — и вставлять нечего.
125
320
  if (!pieces.settings && !pieces.randomUuid && !pieces.clipboard) return
126
321
 
package/lib/mode.js ADDED
@@ -0,0 +1,120 @@
1
+ // Автовыбор режима.
2
+ //
3
+ // Режим сейчас выставляется руками, а при переезде о нём забывают. Ошибка в обе
4
+ // стороны неприятна: лишний слушатель на занятом порту или его отсутствие там,
5
+ // где он нужен.
6
+ //
7
+ // Определяем по одному признаку: отвечает ли что-нибудь на сетевом адресе этой
8
+ // машины по порту харнесса. Харнесс слушает только петлю, поэтому ответ оттуда
9
+ // означает, что сеть уже обслуживает кто-то другой, — то есть прокси.
10
+ //
11
+ // Правило простое и осознанно осторожное: не удалось выяснить — считаем, что
12
+ // прокси есть, и ничего не поднимаем. Лишний слушатель на сетевом адресе — это
13
+ // открытая дверь, и заводить её по догадке нельзя.
14
+ //
15
+ // Чего эта проверка не умеет: заметить прокси, который слушает ДРУГОЙ порт.
16
+ // Такой случай неотличим снаружи от «никого нет», и для него режим задаётся
17
+ // руками. Так и написано в описании плагина.
18
+
19
+ import net from 'node:net'
20
+
21
+ /** Отвечает ли кто-нибудь по этому адресу и порту. */
22
+ function knock(host, port, timeoutMs) {
23
+ return new Promise((resolve) => {
24
+ const socket = net.connect({ host, port })
25
+ const done = (answer) => {
26
+ socket.removeAllListeners()
27
+ try { socket.destroy() } catch (already) { /* уже мертво */ }
28
+ resolve(answer)
29
+ }
30
+ socket.setTimeout(timeoutMs, () => done(false))
31
+ socket.on('connect', () => done(true))
32
+ socket.on('error', () => done(false))
33
+ })
34
+ }
35
+
36
+ /** Свой ли это адрес: петля отвечает всегда и для проверки не годится. */
37
+ export function isLoopback(address) {
38
+ const clean = String(address).trim()
39
+ return clean === 'localhost' || clean === '::1' || clean === '[::1]' || /^127\./.test(clean)
40
+ }
41
+
42
+ /**
43
+ * Годится ли адрес для опроса.
44
+ *
45
+ * Связь-локальные адреса (fe80::) отбрасываются: без указания интерфейса к ним
46
+ * не подключиться, а с ним они всё равно ничего не говорят о том, обслуживает
47
+ * ли кто-то сеть.
48
+ */
49
+ export function worthAsking(address) {
50
+ const clean = String(address ?? '').trim()
51
+ if (!clean || isLoopback(clean)) return false
52
+ if (/^fe80:/i.test(clean)) return false
53
+ if (/^169\.254\./.test(clean)) return false
54
+ // Имя машины опрашивать незачем: оно разрешается в один из тех же адресов.
55
+ return /^[0-9.]+$/.test(clean) || clean.includes(':')
56
+ }
57
+
58
+ /**
59
+ * Выбрать режим.
60
+ *
61
+ * Все адреса опрашиваются разом: их у машины бывает под сотню, и опрос по
62
+ * одному с ожиданием превращает выбор режима в минуты молчания при старте.
63
+ *
64
+ * @param options {{addresses: string[], port: number, directPort?: number,
65
+ * timeoutMs?: number, probe?: Function}}
66
+ * @returns `{ mode: 'proxy'|'direct', reason: string }`
67
+ */
68
+ export async function detectMode(options) {
69
+ const probe = options.probe ?? knock
70
+ const timeoutMs = options.timeoutMs ?? 1000
71
+ const addresses = (options.addresses ?? []).filter(worthAsking)
72
+
73
+ if (!options.port) {
74
+ return { mode: 'proxy', reason: 'порт харнесса неизвестен — ничего не поднимаю' }
75
+ }
76
+ if (addresses.length === 0) {
77
+ return { mode: 'proxy', reason: 'сетевых адресов не нашлось — ничего не поднимаю' }
78
+ }
79
+
80
+ let answers
81
+ try {
82
+ answers = await Promise.all(addresses.map((address) => probe(address, options.port, timeoutMs)))
83
+ } catch (failed) {
84
+ return {
85
+ mode: 'proxy',
86
+ reason: 'проверить не удалось (' + String(failed.message || failed) + ') — ничего не поднимаю',
87
+ }
88
+ }
89
+
90
+ const busy = addresses.filter((address, index) => answers[index])
91
+ if (busy.length) {
92
+ return {
93
+ mode: 'proxy',
94
+ reason: 'по адресу ' + busy[0] + ':' + options.port + ' уже кто-то отвечает — сеть обслуживают без меня',
95
+ }
96
+ }
97
+
98
+ // Свой порт уже занят — поднимать нечего, и молчать об этом нельзя: иначе
99
+ // человек будет искать, почему по нему отвечает не то, что он ждёт.
100
+ if (options.directPort) {
101
+ let taken = false
102
+ try {
103
+ taken = await probe('127.0.0.1', options.directPort, timeoutMs)
104
+ } catch (unknown) {
105
+ taken = false
106
+ }
107
+ if (taken) {
108
+ return {
109
+ mode: 'proxy',
110
+ reason: 'порт ' + options.directPort + ' уже занят — свой слушатель не поднимаю',
111
+ }
112
+ }
113
+ }
114
+
115
+ return {
116
+ mode: 'direct',
117
+ reason: 'на ' + addresses.length + ' сетевых адресах по порту ' + options.port
118
+ + ' никто не отвечает — поднимаю свой слушатель',
119
+ }
120
+ }
package/lib/shim.js CHANGED
@@ -67,35 +67,83 @@
67
67
  // ------------------------------------------------------------- настройки
68
68
  if (!options.settings) return
69
69
 
70
- // Флаг читают трое, и третьему подменять нельзя: в результатах работы он
71
- // решает, можно ли открыть файл локально, и с подменой браузер просил бы
72
- // открыть путь на машине сервера. Поэтому — только два настроечных пакета.
73
- var TARGETS = [
74
- '@deepseek-ai/dsh-client-ui-settings',
75
- '@deepseek-ai/dsh-client-ui-settings-general',
70
+ // Флаг живёт на объекте соединения, и читают его в двух совершенно разных
71
+ // местах.
72
+ //
73
+ // Общий справочник разделов заводится в apply настроечного пакета — там
74
+ // хватало подмены контекста. Но каждый отдельный раздел привязывается иначе:
75
+ //
76
+ // bind(spec) {
77
+ // const ctx = this.ctx // контекст вызывающего
78
+ // const connection = ctx.get('connection')
79
+ // ... connection.isLoopback ? 'host' : 'memory'
80
+ // }
81
+ //
82
+ // this.ctx — контекст того плагина, который зовёт bind. Подменять контекст
83
+ // каждому плагину бесполезно: сервис запоминает не заместителя, а исходный
84
+ // контекст, через который его завели. Поэтому подменяется не контекст, а сам
85
+ // объект соединения, и ровно один раз: он один на весь интерфейс, и после
86
+ // подмены одинаковый ответ видят все — и ядро, и любой плагин со своим
87
+ // разделом настроек.
88
+ //
89
+ // Исключение одно: выдача результатов работы. Там флаг решает, можно ли
90
+ // открыть файл локально, и с подменой браузер просил бы открыть путь на
91
+ // машине сервера. Этому пакету возвращается настоящее значение. Больше флаг
92
+ // во всём интерфейсе не читает никто.
93
+ var EXCLUDED = [
94
+ '@deepseek-ai/dsh-client-ui-deliverables',
76
95
  ]
77
96
 
78
- function connectionWithForcedFlag(connection) {
97
+ /** Настоящий ответ по тому же правилу, что и у ядра: только свои адреса. */
98
+ function realLoopback() {
99
+ var host = location.hostname
100
+ if (host === 'localhost' || host === '[::1]' || host === '::1') return true
101
+ return /^127\./.test(host)
102
+ }
103
+
104
+ // Подмена ставится при первой же возможности: как только у кого-то из
105
+ // пакетов появился контекст, из которого достаётся соединение.
106
+ var forced = false
107
+
108
+ function forceOnConnection(ctx) {
109
+ if (forced) return
110
+ var connection = null
111
+ try { connection = ctx && ctx.get && ctx.get('connection') } catch (noService) { connection = null }
112
+ if (!connection || typeof connection !== 'object') return
113
+ try {
114
+ Object.defineProperty(connection, 'isLoopback', {
115
+ configurable: true,
116
+ get: function () { return loopbackAnswer },
117
+ })
118
+ forced = true
119
+ } catch (cannotDefine) {
120
+ // Свойство неподатливо — оставляем как было: страница будет вести себя
121
+ // так же, как без плагина.
122
+ }
123
+ }
124
+
125
+ function connectionWithRealFlag(connection) {
79
126
  if (!connection || typeof connection !== 'object') return connection
80
127
  return new Proxy(connection, {
81
128
  get: function (target, prop) {
82
- if (prop === 'isLoopback') return loopbackAnswer
129
+ if (prop === 'isLoopback') return realLoopback()
83
130
  var value = Reflect.get(target, prop, target)
84
131
  return typeof value === 'function' ? value.bind(target) : value
85
132
  },
86
133
  })
87
134
  }
88
135
 
89
- function ctxWithForcedConnection(ctx) {
136
+ /** Контекст исключённого пакета: ему возвращается настоящее значение. */
137
+ function ctxWithRealConnection(ctx) {
90
138
  return new Proxy(ctx, {
91
139
  get: function (target, prop) {
92
140
  if (prop === 'get') {
93
141
  return function (nameRequested) {
94
142
  var value = target.get(nameRequested)
95
- return nameRequested === 'connection' ? connectionWithForcedFlag(value) : value
143
+ return nameRequested === 'connection' ? connectionWithRealFlag(value) : value
96
144
  }
97
145
  }
98
- if (prop === 'connection') return connectionWithForcedFlag(Reflect.get(target, prop, target))
146
+ if (prop === 'connection') return connectionWithRealFlag(Reflect.get(target, prop, target))
99
147
  var value = Reflect.get(target, prop, target)
100
148
  return typeof value === 'function' ? value.bind(target) : value
101
149
  },
@@ -103,8 +151,8 @@
103
151
  }
104
152
 
105
153
  function wrap(registration) {
106
- if (!registration || TARGETS.indexOf(registration.id) === -1) return registration
107
- if (typeof registration.factory !== 'function') return registration
154
+ if (!registration || typeof registration.factory !== 'function') return registration
155
+ var excluded = EXCLUDED.indexOf(registration.id) !== -1
108
156
  var factory = registration.factory
109
157
  var patched = {}
110
158
  for (var key in registration) patched[key] = registration[key]
@@ -113,7 +161,12 @@
113
161
  if (!moduleExports || typeof moduleExports.apply !== 'function') return moduleExports
114
162
  var originalApply = moduleExports.apply
115
163
  moduleExports.apply = function (ctx) {
116
- return originalApply.call(this, ctxWithForcedConnection(ctx))
164
+ // Исключённый пакет читает соединение раньше, чем его успели подменить,
165
+ // — но полагаться на это нельзя, поэтому ему всегда отдаётся
166
+ // заместитель с настоящим значением.
167
+ if (excluded) return originalApply.call(this, ctxWithRealConnection(ctx))
168
+ forceOnConnection(ctx)
169
+ return originalApply.call(this, ctx)
117
170
  }
118
171
  return moduleExports
119
172
  }