@goodandready/dsh-lanmode 0.4.0 → 0.5.1

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 CHANGED
@@ -58,7 +58,7 @@ bind(spec) {
58
58
  ```yaml
59
59
  - id: dsh-lanmode
60
60
  config:
61
- mode: proxy # proxy | direct
61
+ mode: proxy # proxy | direct | auto
62
62
  ```
63
63
 
64
64
  **`proxy`** (default) — something already listens on the network in front of the harness: nginx, Caddy, Tailscale serve, an SSH tunnel. The plugin only repairs the page and touches nothing else. This is the safe default: it cannot collide with whatever you already run.
@@ -77,17 +77,127 @@ Then open `http://<the machine's IP>:3088` from any device on the network.
77
77
 
78
78
  A listener rather than rebinding the harness itself, for two reasons: a bind host lives in the config tree and cannot be a switch inside the plugin, and rebinding to `0.0.0.0` collides with a reverse proxy already holding that port.
79
79
 
80
+ **`auto`** — work it out. The plugin knocks on this machine's own network
81
+ addresses at the harness port: the harness itself listens on loopback only, so
82
+ anything answering there is a proxy, and the mode is `proxy`. When nothing
83
+ answers and the direct port is free, it is `direct`. When it cannot tell — no
84
+ addresses, no known port, a probe that errored — it picks `proxy` and opens
85
+ nothing: an unnecessary listener on a network address is an open door, and one
86
+ is not opened on a guess.
87
+
88
+ What `auto` cannot see is a proxy sitting on a *different* port. From outside
89
+ that is indistinguishable from nobody being there, and the plugin would open its
90
+ own listener beside it. Set the mode by hand in that case.
91
+
92
+ The decision is logged with its reason and shown on the diagnostics page.
93
+
80
94
  Changing the mode takes effect on restart.
81
95
 
96
+ ## HTTPS, and the microphone
97
+
98
+ This is the one thing no substitution can repair. A browser hands out
99
+ `navigator.mediaDevices` only over a secure connection, and behind it is a real
100
+ device — there is nothing to fake. Over plain HTTP on a network address, voice
101
+ input is impossible in principle.
102
+
103
+ So the direct-mode listener can speak HTTPS:
104
+
105
+ ```yaml
106
+ - id: dsh-lanmode
107
+ config:
108
+ mode: direct
109
+ tls: self-signed # off | self-signed | files
110
+ ```
111
+
112
+ **`self-signed`** — the plugin issues a certificate itself and keeps it in
113
+ `tlsDir` (by default a folder next to the harness data). It goes into the
114
+ certificate with every address this machine answers on, plus anything in
115
+ `tlsHosts`: a certificate issued for one name is refused for every other, even
116
+ after it has been accepted once. It is reissued when it is about to expire or
117
+ when a new address appears. The fingerprint is printed to the log at startup —
118
+ compare it in the browser instead of accepting blindly.
119
+
120
+ Issuing needs `openssl` on the machine. Without it the plugin says so plainly
121
+ and falls back to plain HTTP rather than pretending everything is fine.
122
+
123
+ **`files`** — your own certificate:
124
+
125
+ ```yaml
126
+ tls: files
127
+ tlsCert: /path/to/cert.pem
128
+ tlsKey: /path/to/key.pem
129
+ ```
130
+
131
+ A self-signed certificate is a compromise, not a solution: the browser will
132
+ still ask. But it turns "impossible" into "confirm once", and that is the whole
133
+ difference between voice input working over the network and not.
134
+
135
+ ## Who may connect
136
+
137
+ The direct listener has no password and will not get one: the plugin does not
138
+ intercept anyone else's routes, and inventing its own way into the harness is
139
+ not its business. But between "no password" and "anyone on the network" there is
140
+ room:
141
+
142
+ ```yaml
143
+ - id: dsh-lanmode
144
+ config:
145
+ mode: direct
146
+ allow:
147
+ - 192.168.1.0/24
148
+ - 10.0.0.5
149
+ ```
150
+
151
+ Addresses and CIDR ranges, IPv4 and IPv6. An empty list means everyone, which is
152
+ how the plugin behaves until you fill it in. Refused connections are logged, at
153
+ a limited rate so a scanner cannot drown the log.
154
+
155
+ Two honest limits. This is not authentication: whoever is on the list gets in
156
+ unchecked. And behind a reverse proxy it means nothing — every request arrives
157
+ from the proxy, so filter there instead.
158
+
159
+ ## Diagnostics
160
+
161
+ `GET /dsh-lanmode/health` — one page answering the questions that otherwise take
162
+ half an hour: which mode is on and why, what is patched, whether the browser
163
+ considers the connection secure, and why the microphone is silent. Add
164
+ `?format=json` for the same data in a form you can paste into a bug report.
165
+
166
+ Half the answers can only come from the browser — a secure connection and a
167
+ microphone exist nowhere else — so the page checks those in the browser that
168
+ opened it.
169
+
170
+ Nothing secret is on that page: it is open to anyone who reached the harness.
171
+ Turn it off with `diagnostics: false`.
172
+
173
+ ## When the harness changes underneath
174
+
175
+ The plugin holds on to the harness's internals: the index tap, the name of the
176
+ package the substitution must leave alone, the shape of the connection object.
177
+ An upgrade can move any of them, and a plugin that repairs someone else's
178
+ behaviour must not fail quietly — that already happened once, and it took days
179
+ of confusing symptoms to notice.
180
+
181
+ So at startup it checks its own assumptions and says what it found: one line
182
+ when everything is in place, a loud complaint naming what moved when it is not.
183
+ The same list is on the diagnostics page.
184
+
82
185
  ## Settings
83
186
 
84
187
  All of it can be edited as the `dsh-lanmode` namespace — in `$DSH_HOME/settings.yaml`, or from the UI once the settings pages work.
85
188
 
86
189
  | Setting | Default | Meaning |
87
190
  |---|---|---|
88
- | `mode` | `proxy` | `proxy` or `direct` |
191
+ | `mode` | `proxy` | `proxy`, `direct` or `auto` |
89
192
  | `directHost` | `0.0.0.0` | `direct`: which address to listen on |
90
193
  | `directPort` | `3088` | `direct`: which port to listen on |
194
+ | `tls` | `off` | `direct`: `off`, `self-signed` or `files` — what the microphone hangs on |
195
+ | `tlsDir` | — | `self-signed`: where the issued certificate is kept |
196
+ | `tlsHosts` | `[]` | `self-signed`: extra names and addresses for the certificate |
197
+ | `tlsCert` | — | `files`: path to the certificate in PEM |
198
+ | `tlsKey` | — | `files`: path to the private key in PEM |
199
+ | `allow` | `[]` | `direct`: addresses and CIDR ranges allowed in. Empty means everyone |
200
+ | `diagnostics` | `true` | serve `GET /dsh-lanmode/health` |
91
201
  | `settings` | `true` | return the settings service |
92
202
  | `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
93
203
  | `clipboard` | `true` | provide a clipboard fallback on plain HTTP |
package/lib/access.js ADDED
@@ -0,0 +1,156 @@
1
+ // Кого пускать в прямом режиме.
2
+ //
3
+ // Пароля здесь нет и не будет: плагин чужие маршруты не перехватывает, а
4
+ // придумывать свой вход в харнесс — не его дело. Но между «без пароля» и «кто
5
+ // угодно из сети» есть промежуток, и список разрешённых адресов его занимает.
6
+ //
7
+ // Разбор записи CIDR свой, без зависимостей: адрес превращается в набор байтов,
8
+ // и сравниваются первые N бит. Для IPv4 и IPv6 это одна и та же арифметика,
9
+ // разной длины.
10
+
11
+ /** Байты адреса IPv4, или `null`, если это не он. */
12
+ function ipv4Bytes(text) {
13
+ const parts = text.split('.')
14
+ if (parts.length !== 4) return null
15
+ const bytes = []
16
+ for (const part of parts) {
17
+ if (!/^\d{1,3}$/.test(part)) return null
18
+ const value = Number(part)
19
+ if (value > 255) return null
20
+ bytes.push(value)
21
+ }
22
+ return bytes
23
+ }
24
+
25
+ /**
26
+ * Байты адреса IPv6, или `null`.
27
+ *
28
+ * Отдельно разбирается запись с хвостом IPv4 (`::ffff:192.168.1.5`): именно её
29
+ * отдаёт Node для обычных подключений на сокете двойного стека, и без неё
30
+ * список разрешённых адресов не сработал бы вовсе.
31
+ */
32
+ function ipv6Bytes(text) {
33
+ let body = text
34
+ let tail = []
35
+ const dot = body.lastIndexOf(':')
36
+ if (body.includes('.')) {
37
+ const four = ipv4Bytes(body.slice(dot + 1))
38
+ if (!four) return null
39
+ tail = four
40
+ body = body.slice(0, dot + 1) + '0:0'
41
+ }
42
+
43
+ const halves = body.split('::')
44
+ if (halves.length > 2) return null
45
+ const head = halves[0] ? halves[0].split(':') : []
46
+ const rest = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : []
47
+ if (halves.length === 1 && head.length !== 8) return null
48
+
49
+ const groups = []
50
+ for (const group of head) {
51
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
52
+ groups.push(Number.parseInt(group, 16))
53
+ }
54
+ const restGroups = []
55
+ for (const group of rest) {
56
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
57
+ restGroups.push(Number.parseInt(group, 16))
58
+ }
59
+ const missing = 8 - groups.length - restGroups.length
60
+ if (missing < 0) return null
61
+ const all = halves.length === 2
62
+ ? groups.concat(new Array(missing).fill(0), restGroups)
63
+ : groups
64
+
65
+ const bytes = []
66
+ for (const group of all) bytes.push(group >> 8, group & 255)
67
+ if (tail.length) {
68
+ bytes.splice(12, 4, ...tail)
69
+ }
70
+ return bytes.length === 16 ? bytes : null
71
+ }
72
+
73
+ /** Байты адреса — или `null`, если разобрать не вышло. */
74
+ export function addressBytes(text) {
75
+ const clean = String(text ?? '').trim()
76
+ if (!clean) return null
77
+ if (clean.includes(':')) return ipv6Bytes(clean)
78
+ return ipv4Bytes(clean)
79
+ }
80
+
81
+ /**
82
+ * Адрес IPv4, спрятанный внутри записи IPv6.
83
+ *
84
+ * `::ffff:192.168.1.5` — это тот же 192.168.1.5, и правило, написанное для
85
+ * IPv4, обязано на него распространяться. Иначе список выглядит рабочим, а
86
+ * пускает мимо.
87
+ */
88
+ function unwrapped(bytes) {
89
+ if (!bytes || bytes.length !== 16) return null
90
+ for (let i = 0; i < 10; i++) if (bytes[i] !== 0) return null
91
+ if (bytes[10] !== 255 || bytes[11] !== 255) return null
92
+ return bytes.slice(12)
93
+ }
94
+
95
+ /**
96
+ * Разобрать одну запись списка: адрес или подсеть в записи CIDR.
97
+ *
98
+ * @returns `{ bytes, bits }` или `null`, если запись непонятна.
99
+ */
100
+ export function parseRule(text) {
101
+ const clean = String(text ?? '').trim()
102
+ if (!clean) return null
103
+ const slash = clean.lastIndexOf('/')
104
+ const address = slash === -1 ? clean : clean.slice(0, slash)
105
+ const bytes = addressBytes(address)
106
+ if (!bytes) return null
107
+
108
+ const full = bytes.length * 8
109
+ if (slash === -1) return { bytes, bits: full }
110
+ const bits = Number(clean.slice(slash + 1))
111
+ if (!Number.isInteger(bits) || bits < 0 || bits > full) return null
112
+ return { bytes, bits }
113
+ }
114
+
115
+ /** Разобрать весь список, молча отбрасывая непонятные записи. */
116
+ export function parseAllow(list) {
117
+ const rules = []
118
+ const dropped = []
119
+ for (const item of Array.isArray(list) ? list : []) {
120
+ const rule = parseRule(item)
121
+ if (rule) rules.push(rule)
122
+ else if (String(item ?? '').trim()) dropped.push(String(item))
123
+ }
124
+ return { rules, dropped }
125
+ }
126
+
127
+ /** Совпадают ли первые `bits` бит. */
128
+ function samePrefix(left, right, bits) {
129
+ if (left.length !== right.length) return false
130
+ const whole = bits >> 3
131
+ for (let i = 0; i < whole; i++) if (left[i] !== right[i]) return false
132
+ const spare = bits & 7
133
+ if (spare === 0) return true
134
+ const mask = (255 << (8 - spare)) & 255
135
+ return (left[whole] & mask) === (right[whole] & mask)
136
+ }
137
+
138
+ /**
139
+ * Пускать ли этот адрес.
140
+ *
141
+ * Пустой список означает «никого не ограничиваем» — так плагин ведёт себя до
142
+ * того, как список задали, и так же, если задали одну мусорную строку: тихо
143
+ * запереть дверь из-за опечатки хуже, чем не запирать вовсе.
144
+ */
145
+ export function allowed(address, rules) {
146
+ if (!Array.isArray(rules) || rules.length === 0) return true
147
+ const bytes = addressBytes(address)
148
+ if (!bytes) return false
149
+ const inner = unwrapped(bytes)
150
+
151
+ for (const rule of rules) {
152
+ if (samePrefix(bytes, rule.bytes, rule.bits)) return true
153
+ if (inner && rule.bytes.length === 4 && samePrefix(inner, rule.bytes, rule.bits)) return true
154
+ }
155
+ return false
156
+ }
@@ -0,0 +1,76 @@
1
+ // Проверка точек крепления.
2
+ //
3
+ // Плагин чинит чужое поведение и держится за внутренности харнесса: за точку
4
+ // вставки в index.html, за то, что вставка доезжает до отдаваемой страницы, и
5
+ // за имя пакета, который заплатка обязана обходить стороной. Любая из точек
6
+ // может уехать с обновлением ядра.
7
+ //
8
+ // Так уже было: подмена доходила до настроек ядра, но не до разделов плагинов,
9
+ // и это выяснилось не сразу, а через несколько дней жалоб на пустые карточки.
10
+ // Тихий отказ здесь хуже поломки — поэтому плагин проверяет свои допущения сам
11
+ // и жалуется громко.
12
+
13
+ /** Пакет, которому заплатка обязана оставлять настоящий ответ. */
14
+ export const EXCLUDED_BUNDLE = '@deepseek-ai/dsh-client-ui-deliverables'
15
+
16
+ /** Один вывод проверки. */
17
+ function verdict(name, ok, detail) {
18
+ return { name, ok, detail }
19
+ }
20
+
21
+ /**
22
+ * Проверить всё, что можно проверить со стороны хоста.
23
+ *
24
+ * Со стороны браузера проверяет страница диагностики: то, что происходит в нём,
25
+ * отсюда не видно, а гадать — то же самое, что не проверять.
26
+ *
27
+ * @param options {{webServer: object, fetchIndex: () => Promise<string>}}
28
+ */
29
+ export async function checkAssumptions(options) {
30
+ const results = []
31
+ const webServer = options.webServer
32
+
33
+ results.push(verdict(
34
+ 'точка вставки в index.html',
35
+ Boolean(webServer && typeof webServer.tapIndex === 'function'),
36
+ 'webServer.tapIndex — то, чем плагин вставляет заплатку на страницу',
37
+ ))
38
+
39
+ results.push(verdict(
40
+ 'порт харнесса известен',
41
+ Boolean(webServer && webServer.port),
42
+ 'webServer.port — без него не поднять прямой режим и не проверить, кто слушает сеть',
43
+ ))
44
+
45
+ let html = ''
46
+ try {
47
+ html = await options.fetchIndex()
48
+ } catch (unreachable) {
49
+ results.push(verdict('страница отдаётся', false, String(unreachable.message || unreachable)))
50
+ return results
51
+ }
52
+
53
+ results.push(verdict(
54
+ 'заплатка попала на страницу',
55
+ html.includes('data-dsh-lanmode'),
56
+ 'если её там нет, всё остальное не имеет значения',
57
+ ))
58
+
59
+ results.push(verdict(
60
+ 'исключение на месте',
61
+ html.includes(EXCLUDED_BUNDLE),
62
+ 'пакет ' + EXCLUDED_BUNDLE + ' обходится стороной, потому что решает, можно ли '
63
+ + 'открыть файл локально. Исчез или переименован — исключение больше ничего не исключает',
64
+ ))
65
+
66
+ return results
67
+ }
68
+
69
+ /** Свести проверки в одну строку для журнала. */
70
+ export function summarize(results) {
71
+ const bad = results.filter((item) => !item.ok)
72
+ if (bad.length === 0) return 'точки крепления на месте: ' + results.length + ' из ' + results.length
73
+ return 'ТОЧКИ КРЕПЛЕНИЯ УЕХАЛИ (' + bad.length + ' из ' + results.length + '): '
74
+ + bad.map((item) => item.name).join('; ')
75
+ + '. Плагин может работать не так, как задумано, — вероятно, обновилось ядро'
76
+ }
package/lib/bridge.js CHANGED
@@ -1,92 +1,146 @@
1
- // Прямой режим: слушатель на сетевом адресе, который передаёт всё харнессу.
2
- //
3
- // Зачем не привязка харнесса к 0.0.0.0, как делают соседние плагины. Привязка
4
- // задаётся в дереве конфигурации и меняется только перезапуском, то есть
5
- // «переключателем в плагине» быть не может. Хуже того, если перед харнессом
6
- // уже стоит обратный прокси на том же порту, привязка столкнётся с ним лбами.
7
- // Отдельный слушатель включается и гасится вместе со строкой плагина и живёт
8
- // рядом с любым прокси.
9
- //
10
- // Заголовки Host и Origin переписываются на локальные: харнесс пропускает
11
- // запрос, только когда Origin совпадает с адресом, по которому он слушает.
12
- // Это ровно то, что делает любой обратный прокси, и это же означает, что
13
- // прямой режим открывает харнесс всем, кто дотянется до этого порта, — как и
14
- // nginx сегодня. Пароля здесь нет и быть не может: плагин чужие маршруты не
15
- // перехватывает.
16
-
17
- import http from 'node:http'
18
-
19
- function rewritten(headers, authority) {
20
- const out = { ...headers, host: authority }
21
- if (out.origin) out.origin = 'http://' + authority
22
- if (out.referer) out.referer = String(out.referer).replace(/^https?:\/\/[^/]+/, 'http://' + authority)
23
- return out
24
- }
25
-
26
- /**
27
- * @param ctx контекст плагина (нужен ctx.webServer.port)
28
- * @param options {{host: string, port: number, log: (message: string) => void}}
29
- * @returns функция остановки
30
- */
31
- export function startDirectBridge(ctx, options) {
32
- const upstreamPort = ctx.webServer.port
33
- if (!upstreamPort) {
34
- options.log('прямой режим не поднят: веб-сервер ещё не сообщил порт')
35
- return () => {}
36
- }
37
- const authority = '127.0.0.1:' + upstreamPort
38
-
39
- const bridge = http.createServer((req, res) => {
40
- const upstream = http.request({
41
- host: '127.0.0.1',
42
- port: upstreamPort,
43
- method: req.method,
44
- path: req.url,
45
- headers: rewritten(req.headers, authority),
46
- }, (answer) => {
47
- res.writeHead(answer.statusCode || 502, answer.headers)
48
- answer.pipe(res)
49
- })
50
- upstream.on('error', () => {
51
- if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
52
- res.end('dsh-lanmode: харнесс не отвечает')
53
- })
54
- req.pipe(upstream)
55
- })
56
-
57
- // Веб-сокеты интерфейса идут через Upgrade: их надо передать сырыми.
58
- bridge.on('upgrade', (req, socket, head) => {
59
- const upstream = http.request({
60
- host: '127.0.0.1',
61
- port: upstreamPort,
62
- method: req.method,
63
- path: req.url,
64
- headers: rewritten(req.headers, authority),
65
- })
66
- upstream.on('upgrade', (answer, upstreamSocket, upstreamHead) => {
67
- const lines = ['HTTP/1.1 101 Switching Protocols']
68
- for (const [key, value] of Object.entries(answer.headers)) lines.push(key + ': ' + value)
69
- socket.write(lines.join('\r\n') + '\r\n\r\n')
70
- if (upstreamHead && upstreamHead.length) socket.unshift(upstreamHead)
71
- upstreamSocket.pipe(socket)
72
- socket.pipe(upstreamSocket)
73
- const drop = () => { try { upstreamSocket.destroy() } catch (already) { /* уже мертво */ } }
74
- socket.on('error', drop)
75
- socket.on('close', drop)
76
- })
77
- upstream.on('error', () => { try { socket.destroy() } catch (already) { /* уже мертво */ } })
78
- if (head && head.length) upstream.write(head)
79
- upstream.end()
80
- })
81
-
82
- bridge.on('error', (failure) => {
83
- options.log('прямой режим не поднялся: ' + String(failure && failure.message || failure))
84
- })
85
-
86
- bridge.listen(options.port, options.host, () => {
87
- options.log('прямой режим: слушаю ' + options.host + ':' + options.port
88
- + ', передаю на ' + authority)
89
- })
90
-
91
- return () => { bridge.close() }
92
- }
1
+ // Прямой режим: слушатель на сетевом адресе, который передаёт всё харнессу.
2
+ //
3
+ // Зачем не привязка харнесса к 0.0.0.0, как делают соседние плагины. Привязка
4
+ // задаётся в дереве конфигурации и меняется только перезапуском, то есть
5
+ // «переключателем в плагине» быть не может. Хуже того, если перед харнессом
6
+ // уже стоит обратный прокси на том же порту, привязка столкнётся с ним лбами.
7
+ // Отдельный слушатель включается и гасится вместе со строкой плагина и живёт
8
+ // рядом с любым прокси.
9
+ //
10
+ // Заголовки Host и Origin переписываются на локальные: харнесс пропускает
11
+ // запрос, только когда Origin совпадает с адресом, по которому он слушает.
12
+ // Это ровно то, что делает любой обратный прокси.
13
+ //
14
+ // Кого пускать решает список разрешённых адресов, если он задан. Это не
15
+ // замена паролю: тот, кто в списке, входит без всякой проверки. Это сужение
16
+ // круга, и в описании плагина так и сказано.
17
+
18
+ import http from 'node:http'
19
+ import https from 'node:https'
20
+
21
+ import { allowed } from './access.js'
22
+
23
+ function rewritten(headers, authority) {
24
+ const out = { ...headers, host: authority }
25
+ if (out.origin) out.origin = 'http://' + authority
26
+ if (out.referer) out.referer = String(out.referer).replace(/^https?:\/\/[^/]+/, 'http://' + authority)
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * Ограничитель частоты жалоб.
32
+ *
33
+ * Сканер из сети даёт сотни отказов в минуту, и без ограничения журнал
34
+ * превращается в поток одинаковых строк, в котором не видно ничего другого.
35
+ */
36
+ function throttle(log, everyMs) {
37
+ let last = 0
38
+ let skipped = 0
39
+ return (message) => {
40
+ const now = Date.now()
41
+ if (now - last < everyMs) {
42
+ skipped += 1
43
+ return
44
+ }
45
+ log(skipped ? message + ' (и ещё ' + skipped + ' таких же)' : message)
46
+ last = now
47
+ skipped = 0
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param ctx контекст плагина (нужен ctx.webServer.port)
53
+ * @param options {{host: string, port: number, log: (message: string) => void,
54
+ * allow?: object[], tls?: {cert: string, key: string}}}
55
+ * @returns функция остановки
56
+ */
57
+ export function startDirectBridge(ctx, options) {
58
+ const upstreamPort = ctx.webServer.port
59
+ if (!upstreamPort) {
60
+ options.log('прямой режим не поднят: веб-сервер ещё не сообщил порт')
61
+ return () => {}
62
+ }
63
+ const authority = '127.0.0.1:' + upstreamPort
64
+ const rules = options.allow ?? []
65
+ const refuse = throttle(options.log, 10000)
66
+
67
+ /** Пускать ли этого гостя; отказ пишется в журнал не чаще раза в десять секунд. */
68
+ const welcome = (address) => {
69
+ if (allowed(address, rules)) return true
70
+ refuse('отказано: адрес ' + String(address) + ' не в списке разрешённых')
71
+ return false
72
+ }
73
+
74
+ const handle = (req, res) => {
75
+ if (!welcome(req.socket.remoteAddress)) {
76
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
77
+ res.end('forbidden')
78
+ return
79
+ }
80
+ const upstream = http.request({
81
+ host: '127.0.0.1',
82
+ port: upstreamPort,
83
+ method: req.method,
84
+ path: req.url,
85
+ headers: rewritten(req.headers, authority),
86
+ }, (answer) => {
87
+ res.writeHead(answer.statusCode || 502, answer.headers)
88
+ answer.pipe(res)
89
+ })
90
+ upstream.on('error', () => {
91
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
92
+ res.end('dsh-lanmode: харнесс не отвечает')
93
+ })
94
+ req.pipe(upstream)
95
+ }
96
+
97
+ const bridge = options.tls
98
+ ? https.createServer({ cert: options.tls.cert, key: options.tls.key }, handle)
99
+ : http.createServer(handle)
100
+
101
+ // Веб-сокеты интерфейса идут через Upgrade: их надо передать сырыми. Проверка
102
+ // адреса здесь такая же: пропустить веб-сокеты — значит не сделать ничего,
103
+ // весь разговор с агентом идёт именно по ним.
104
+ bridge.on('upgrade', (req, socket, head) => {
105
+ if (!welcome(socket.remoteAddress)) {
106
+ try {
107
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
108
+ socket.destroy()
109
+ } catch (already) { /* уже мертво */ }
110
+ return
111
+ }
112
+ const upstream = http.request({
113
+ host: '127.0.0.1',
114
+ port: upstreamPort,
115
+ method: req.method,
116
+ path: req.url,
117
+ headers: rewritten(req.headers, authority),
118
+ })
119
+ upstream.on('upgrade', (answer, upstreamSocket, upstreamHead) => {
120
+ const lines = ['HTTP/1.1 101 Switching Protocols']
121
+ for (const [key, value] of Object.entries(answer.headers)) lines.push(key + ': ' + value)
122
+ socket.write(lines.join('\r\n') + '\r\n\r\n')
123
+ if (upstreamHead && upstreamHead.length) socket.unshift(upstreamHead)
124
+ upstreamSocket.pipe(socket)
125
+ socket.pipe(upstreamSocket)
126
+ const drop = () => { try { upstreamSocket.destroy() } catch (already) { /* уже мертво */ } }
127
+ socket.on('error', drop)
128
+ socket.on('close', drop)
129
+ })
130
+ upstream.on('error', () => { try { socket.destroy() } catch (already) { /* уже мертво */ } })
131
+ if (head && head.length) upstream.write(head)
132
+ upstream.end()
133
+ })
134
+
135
+ bridge.on('error', (failure) => {
136
+ options.log('прямой режим не поднялся: ' + String(failure && failure.message || failure))
137
+ })
138
+
139
+ bridge.listen(options.port, options.host, () => {
140
+ options.log('прямой режим: слушаю ' + (options.tls ? 'https://' : 'http://')
141
+ + options.host + ':' + options.port + ', передаю на ' + authority
142
+ + (rules.length ? ', пускаю ' + rules.length + ' правил(о) из списка' : ''))
143
+ })
144
+
145
+ return () => { bridge.close() }
146
+ }
package/lib/health.js ADDED
@@ -0,0 +1,119 @@
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
+ // Проверять наличие заплатки на ЭТОЙ странице бессмысленно: она вставляется
43
+ // в страницу интерфейса, а не в нашу, и ответ был бы всегда «нет» — ложная
44
+ // тревога того сорта, из-за которой потом ищут несуществующую поломку.
45
+ // Поэтому спрашиваем ту страницу, куда она и вставляется.
46
+ ['заплатка на странице интерфейса', 'проверяется',
47
+ 'если нет — скрипт не попал на страницу или его выключили через ?lanmode=off'],
48
+ ['страница считается своей', /^(localhost|\\[::1\\]|::1|127\\.)/.test(location.hostname),
49
+ 'на чужом имени харнесс уводит настройки в режим памяти; это и чинит заплатка']
50
+ ]
51
+ function draw() {
52
+ out.innerHTML = '<table>' + checks.map(function (item) {
53
+ var mark = item[1] === 'проверяется' ? '…' : (item[1] ? '✔' : '✘')
54
+ return '<tr><td>' + mark + '</td><td>' + item[0] + '</td><td>'
55
+ + (item[1] === true || item[1] === 'проверяется' ? '' : item[2]) + '</td></tr>'
56
+ }).join('') + '</table>'
57
+ window.__DSH_LANMODE_BROWSER__ = checks.map(function (item) {
58
+ return { name: item[0], ok: item[1] }
59
+ })
60
+ }
61
+ draw()
62
+
63
+ // Страницу интерфейса спрашиваем отдельно и дорисовываем ответ: она приходит
64
+ // не мгновенно, а держать из-за неё всю таблицу незачем.
65
+ var at = checks.findIndex(function (item) { return item[0].indexOf('заплатка') === 0 })
66
+ fetch('/', { cache: 'no-store' })
67
+ .then(function (answer) { return answer.text() })
68
+ .then(function (html) { checks[at][1] = html.indexOf('data-dsh-lanmode') !== -1; draw() })
69
+ .catch(function () { checks[at][1] = false; draw() })
70
+ })()
71
+ `
72
+
73
+ function row(item) {
74
+ return '<tr><td>' + (item.ok ? '✔' : '✘') + '</td><td>' + escapeHtml(item.name)
75
+ + '</td><td>' + (item.ok ? '' : escapeHtml(item.detail || '')) + '</td></tr>'
76
+ }
77
+
78
+ function escapeHtml(text) {
79
+ return String(text)
80
+ .replace(/&/g, '&amp;')
81
+ .replace(/</g, '&lt;')
82
+ .replace(/>/g, '&gt;')
83
+ }
84
+
85
+ /** Страница целиком. */
86
+ export function healthPage(state) {
87
+ const report = hostReport(state)
88
+ const listener = report.listener
89
+ ? escapeHtml(report.listener.scheme + '://' + report.listener.host + ':' + report.listener.port)
90
+ : 'нет — сеть обслуживает кто-то другой'
91
+
92
+ return '<!doctype html><html lang="ru"><head><meta charset="utf-8">'
93
+ + '<meta name="viewport" content="width=device-width, initial-scale=1">'
94
+ + '<title>dsh-lanmode</title><style>'
95
+ + 'body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;max-width:900px}'
96
+ + 'h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:24px 0 8px}'
97
+ + 'table{border-collapse:collapse;width:100%}'
98
+ + 'td{padding:4px 8px;border-top:1px solid rgba(128,128,128,.3);vertical-align:top}'
99
+ + 'td:first-child{width:1.5em;text-align:center}td:nth-child(2){width:16em}'
100
+ + 'td:nth-child(3){color:#888}dl{margin:0}dt{color:#888;font-size:12px;margin-top:8px}'
101
+ + '@media(prefers-color-scheme:dark){body{background:#111;color:#ddd}}'
102
+ + '</style></head><body>'
103
+ + '<h1>dsh-lanmode ' + escapeHtml(report.version) + '</h1>'
104
+ + '<dl><dt>режим</dt><dd>' + escapeHtml(report.mode)
105
+ + (report.modeReason ? ' — ' + escapeHtml(report.modeReason) : '') + '</dd>'
106
+ + '<dt>слушатель</dt><dd>' + listener + '</dd>'
107
+ + '<dt>сертификат</dt><dd>' + (report.tls.enabled
108
+ ? escapeHtml(report.tls.source + ', отпечаток ' + report.tls.fingerprint)
109
+ : 'нет') + '</dd>'
110
+ + '<dt>подменено</dt><dd>' + escapeHtml(Object.entries(report.pieces)
111
+ .filter(([, on]) => on).map(([name]) => name).join(', ') || 'ничего') + '</dd>'
112
+ + '<dt>пускаю</dt><dd>' + escapeHtml(report.allow.length ? report.allow.join(', ') : 'всех') + '</dd>'
113
+ + '</dl>'
114
+ + '<h2>Точки крепления</h2><table>' + report.assumptions.map(row).join('') + '</table>'
115
+ + '<h2>Что видит браузер</h2><div id="browser"></div>'
116
+ + '<h2>Для отчёта об ошибке</h2><p>Те же данные в JSON: '
117
+ + '<a href="?format=json">?format=json</a></p>'
118
+ + '<script>' + BROWSER_SCRIPT + '</script></body></html>'
119
+ }
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/tls.js ADDED
@@ -0,0 +1,173 @@
1
+ // Сертификат для прямого режима.
2
+ //
3
+ // Зачем вообще. Микрофон браузер отдаёт только на защищённом соединении, и это
4
+ // единственное, чего не лечит подмена на странице: `navigator.mediaDevices`
5
+ // подделать нечем — за ним настоящее устройство. Пока прямой режим слушает
6
+ // голый HTTP, голосовой ввод по сети невозможен в принципе.
7
+ //
8
+ // Самоподписанный сертификат — компромисс, а не решение: браузер всё равно
9
+ // спросит. Но он превращает «невозможно» в «подтвердить один раз», а это
10
+ // разница между «не работает» и «работает».
11
+ //
12
+ // Сертификат делается через openssl. Node умеет породить ключевую пару, но не
13
+ // собрать из неё X.509: это ASN.1 вручную, сотни строк ради того, что уже
14
+ // лежит в /usr/bin. Нет openssl — честно говорим об этом и предлагаем свой
15
+ // сертификат, а не делаем вид, что всё в порядке.
16
+
17
+ import { execFile } from 'node:child_process'
18
+ import { X509Certificate } from 'node:crypto'
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ import { addressBytes } from './access.js'
24
+
25
+ /** За сколько до истечения перевыпускать. */
26
+ const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000
27
+
28
+ /** Сколько живёт выпущенный нами сертификат. */
29
+ const LIFETIME_DAYS = 397
30
+
31
+ /**
32
+ * Адреса, по которым к этой машине могут обратиться.
33
+ *
34
+ * В сертификат идут все: браузер сверяет тот адрес, который набрали в строке,
35
+ * и сертификат, выписанный на одно имя, ругается на все остальные — даже после
36
+ * того, как его один раз приняли.
37
+ */
38
+ export function localAddresses() {
39
+ const found = new Set(['localhost', '127.0.0.1', '::1'])
40
+ const interfaces = os.networkInterfaces()
41
+ for (const list of Object.values(interfaces)) {
42
+ for (const item of list ?? []) {
43
+ if (!item || item.internal) continue
44
+ if (item.address) found.add(item.address)
45
+ }
46
+ }
47
+ const hostname = os.hostname()
48
+ if (hostname) found.add(hostname)
49
+ return [...found]
50
+ }
51
+
52
+ /** Строка subjectAltName для openssl: имена — DNS, адреса — IP. */
53
+ export function altNames(hosts) {
54
+ const parts = []
55
+ for (const host of hosts) {
56
+ const clean = String(host ?? '').trim()
57
+ if (!clean) continue
58
+ const isAddress = /^[0-9.]+$/.test(clean) || clean.includes(':')
59
+ parts.push((isAddress ? 'IP:' : 'DNS:') + clean)
60
+ }
61
+ return parts.join(',')
62
+ }
63
+
64
+ function run(command, args) {
65
+ return new Promise((resolve, reject) => {
66
+ execFile(command, args, { timeout: 30000 }, (error, stdout, stderr) => {
67
+ if (error) reject(new Error(String(stderr || error.message).trim()))
68
+ else resolve(String(stdout))
69
+ })
70
+ })
71
+ }
72
+
73
+ /** Что известно о лежащем сертификате: до какого числа и на какие имена. */
74
+ export function inspect(certPem) {
75
+ const certificate = new X509Certificate(certPem)
76
+ const names = String(certificate.subjectAltName ?? '')
77
+ .split(',')
78
+ .map((part) => part.trim().replace(/^(DNS|IP Address|IP):/, ''))
79
+ .filter(Boolean)
80
+ return {
81
+ validTo: new Date(certificate.validTo),
82
+ fingerprint: certificate.fingerprint256,
83
+ names,
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Годится ли лежащий сертификат: не истекает ли и покрывает ли нужные адреса.
89
+ *
90
+ * Второе важнее первого. Появился новый сетевой адрес — старый сертификат
91
+ * формально жив, но по новому адресу браузер его не примет, и человек будет
92
+ * гадать, почему «вчера работало».
93
+ */
94
+ /**
95
+ * Одно и то же имя, записанное одинаково.
96
+ *
97
+ * Адрес можно записать по-разному: openssl превращает в
98
+ * , и сравнение строк на этом рассыпается. Молча, с
99
+ * единственным следствием: сертификат перевыпускается при каждом запуске, а
100
+ * браузер каждый раз требует подтверждения заново — то есть смысл затеи
101
+ * пропадает.
102
+ */
103
+ function canonical(name) {
104
+ const bytes = addressBytes(name)
105
+ if (bytes) return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('')
106
+ return String(name).trim().toLowerCase()
107
+ }
108
+
109
+ export function stillGood(info, hosts, now) {
110
+ if (!info) return false
111
+ if (info.validTo.getTime() - now < RENEW_BEFORE_MS) return false
112
+ const covered = new Set(info.names.map(canonical))
113
+ return hosts.every((host) => covered.has(canonical(host)))
114
+ }
115
+
116
+ /**
117
+ * Взять готовый сертификат или выпустить новый.
118
+ *
119
+ * @param options {{dir: string, hosts: string[], log: (message: string) => void, now?: number}}
120
+ * @returns `{ cert, key, fingerprint, issued }`
121
+ */
122
+ export async function ensureCertificate(options) {
123
+ const dir = options.dir
124
+ const hosts = options.hosts
125
+ const now = options.now ?? Date.now()
126
+ const certPath = path.join(dir, 'lanmode-cert.pem')
127
+ const keyPath = path.join(dir, 'lanmode-key.pem')
128
+
129
+ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
130
+ try {
131
+ const cert = fs.readFileSync(certPath, 'utf8')
132
+ const info = inspect(cert)
133
+ if (stillGood(info, hosts, now)) {
134
+ return {
135
+ cert,
136
+ key: fs.readFileSync(keyPath, 'utf8'),
137
+ fingerprint: info.fingerprint,
138
+ issued: false,
139
+ }
140
+ }
141
+ options.log('сертификат перевыпускается: истекает или не покрывает все адреса')
142
+ } catch (unreadable) {
143
+ options.log('сертификат нечитаем, выпускаю заново: ' + String(unreadable.message || unreadable))
144
+ }
145
+ }
146
+
147
+ fs.mkdirSync(dir, { recursive: true })
148
+ await run('openssl', [
149
+ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256',
150
+ '-days', String(LIFETIME_DAYS),
151
+ '-keyout', keyPath,
152
+ '-out', certPath,
153
+ '-subj', '/CN=' + (hosts[0] || 'localhost'),
154
+ '-addext', 'subjectAltName=' + altNames(hosts),
155
+ ])
156
+ // Ключ читаем не только мы: пусть его не читает никто, кроме владельца.
157
+ fs.chmodSync(keyPath, 0o600)
158
+
159
+ const cert = fs.readFileSync(certPath, 'utf8')
160
+ return {
161
+ cert,
162
+ key: fs.readFileSync(keyPath, 'utf8'),
163
+ fingerprint: inspect(cert).fingerprint,
164
+ issued: true,
165
+ }
166
+ }
167
+
168
+ /** Прочитать сертификат и ключ, указанные человеком. */
169
+ export function readCertificate(certPath, keyPath) {
170
+ const cert = fs.readFileSync(certPath, 'utf8')
171
+ const key = fs.readFileSync(keyPath, 'utf8')
172
+ return { cert, key, fingerprint: inspect(cert).fingerprint, issued: false }
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-lanmode",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "LAN and reverse-proxy access for the DeepSeek Harness Web UI: returns the settings service on pages that are not localhost, fills in the Web APIs the browser withholds on plain HTTP, and can open a listener of its own so nothing else is needed.",
5
5
  "license": "MIT",
6
6
  "type": "module",