@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/README.md +126 -4
- package/lib/access.js +156 -0
- package/lib/assumptions.js +76 -0
- package/lib/bridge.js +146 -92
- package/lib/health.js +104 -0
- package/lib/index.js +214 -19
- package/lib/mode.js +120 -0
- package/lib/shim.js +67 -14
- package/lib/tls.js +173 -0
- package/package.json +1 -1
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, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
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
|
-
|
|
166
|
+
once(sctx, effective)
|
|
97
167
|
})
|
|
98
168
|
|
|
99
|
-
// Без
|
|
169
|
+
// Без службы настроек тоже должно работать — тогда только дерево плагинов.
|
|
170
|
+
// Ждём: службы может не быть вовсе, а может не быть ещё. Различить это можно
|
|
171
|
+
// только временем, и лучше подождать, чем запуститься не тем набором.
|
|
100
172
|
ctx.effect(() => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}, 'dsh-lanmode: запуск без сервиса настроек')
|
|
173
|
+
const timer = setTimeout(() => once(ctx, config), 2000)
|
|
174
|
+
return () => clearTimeout(timer)
|
|
175
|
+
}, 'dsh-lanmode: запуск без службы настроек')
|
|
105
176
|
}
|
|
106
177
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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' ?
|
|
143
|
+
return nameRequested === 'connection' ? connectionWithRealFlag(value) : value
|
|
96
144
|
}
|
|
97
145
|
}
|
|
98
|
-
if (prop === 'connection') return
|
|
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 ||
|
|
107
|
-
|
|
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
|
-
|
|
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
|
}
|