@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 CHANGED
@@ -35,18 +35,30 @@ Everything happens through the web server's official index tap: one script is in
35
35
 
36
36
  | Piece | Setting | What it does |
37
37
  |---|---|---|
38
- | Settings | `settings` | Hands the two settings packages a connection whose `isLoopback` reads `true`. Everything downstream — the shared mirror, every namespace scope, the core's own pages then behaves as it does on localhost. |
38
+ | Settings | `settings` | Hands every package a connection whose `isLoopback` reads `true`. The shared mirror, every namespace scope, the core's own pages and every plugin's settings section then behave as they do on localhost. |
39
39
  | `crypto.randomUUID` | `randomUuid` | Provides an RFC 4122 v4 implementation over `crypto.getRandomValues`, which insecure origins do have. A no-op where the real one exists. |
40
40
  | `navigator.clipboard` | `clipboard` | Provides a `writeText` fallback so the copy buttons keep working. A no-op where the real one exists. |
41
41
 
42
- The settings substitution is deliberately narrow. Three packages read that flag, and the third is deliverables, where it decides whether a produced file may be opened locally forcing it there would ask the Host to open paths on the server's desktop. Only the settings packages see the substitute.
42
+ One package is excluded on purpose: deliverables, where the flag decides whether a produced file may be opened locally. Forcing it there would ask the Host to open paths on the server's desktop. Nothing else in the web UI reads the flag.
43
+
44
+ The exclusion list replaced an allow list, and the reason is worth writing down. A namespace scope is bound like this:
45
+
46
+ ```js
47
+ bind(spec) {
48
+ const ctx = this.ctx // the caller's context
49
+ const connection = ctx.get('connection')
50
+ ... connection.isLoopback ? 'host' : 'memory'
51
+ }
52
+ ```
53
+
54
+ `this.ctx` belongs to whichever plugin calls `bind`, not to the settings package. Handing the substitute to the settings packages alone therefore fixed the shared mirror and the core's own pages, while every plugin's own settings section still went to memory mode and reported that the harness had not announced its settings.
43
55
 
44
56
  ## Two modes
45
57
 
46
58
  ```yaml
47
59
  - id: dsh-lanmode
48
60
  config:
49
- mode: proxy # proxy | direct
61
+ mode: proxy # proxy | direct | auto
50
62
  ```
51
63
 
52
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.
@@ -65,17 +77,127 @@ Then open `http://<the machine's IP>:3088` from any device on the network.
65
77
 
66
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.
67
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
+
68
94
  Changing the mode takes effect on restart.
69
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
+
70
185
  ## Settings
71
186
 
72
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.
73
188
 
74
189
  | Setting | Default | Meaning |
75
190
  |---|---|---|
76
- | `mode` | `proxy` | `proxy` or `direct` |
191
+ | `mode` | `proxy` | `proxy`, `direct` or `auto` |
77
192
  | `directHost` | `0.0.0.0` | `direct`: which address to listen on |
78
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` |
79
201
  | `settings` | `true` | return the settings service |
80
202
  | `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
81
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
+ }