@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/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
|
|
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
|
-
|
|
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 `
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|