@goodandready/dsh-lanmode 0.5.0 → 0.6.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 +88 -0
- package/lib/bind.js +25 -0
- package/lib/bridge.js +79 -20
- package/lib/health.js +33 -10
- package/lib/index.js +50 -3
- package/lib/privileged.js +49 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,6 +132,64 @@ A self-signed certificate is a compromise, not a solution: the browser will
|
|
|
132
132
|
still ask. But it turns "impossible" into "confirm once", and that is the whole
|
|
133
133
|
difference between voice input working over the network and not.
|
|
134
134
|
|
|
135
|
+
## Replacing a reverse proxy
|
|
136
|
+
|
|
137
|
+
Direct mode is meant to be the whole answer: install the plugin, set two fields,
|
|
138
|
+
switch the proxy off. What a reverse proxy in front of the harness has to do,
|
|
139
|
+
the bridge does — rewrite `Host` and `Origin` so the same-origin fence passes,
|
|
140
|
+
carry WebSockets, keep long-lived streams alive, terminate TLS.
|
|
141
|
+
|
|
142
|
+
**Moving over without anyone noticing.** Keep the address, the port and the
|
|
143
|
+
certificate the browsers have already accepted:
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
- id: dsh-lanmode
|
|
147
|
+
config:
|
|
148
|
+
mode: direct
|
|
149
|
+
directPort: 3080 # the port the proxy served on
|
|
150
|
+
tls: files
|
|
151
|
+
tlsCert: /path/to/your/existing/fullchain.pem
|
|
152
|
+
tlsKey: /path/to/your/existing/key.pem
|
|
153
|
+
unlockPrivileged: true
|
|
154
|
+
allow:
|
|
155
|
+
- 192.168.0.0/16
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Then stop the proxy. Nothing changes for anyone: same URL, same certificate, no
|
|
159
|
+
second confirmation.
|
|
160
|
+
|
|
161
|
+
The harness already holds that port on the loopback, so `directHost` is left
|
|
162
|
+
alone: the plugin notices the clash and binds this machine's network addresses
|
|
163
|
+
by name instead of binding everything. It says so in the log.
|
|
164
|
+
|
|
165
|
+
**Starting from nothing.** No proxy, no certificate:
|
|
166
|
+
|
|
167
|
+
```yaml
|
|
168
|
+
- id: dsh-lanmode
|
|
169
|
+
config:
|
|
170
|
+
mode: direct
|
|
171
|
+
directPort: 3088
|
|
172
|
+
tls: self-signed
|
|
173
|
+
unlockPrivileged: true
|
|
174
|
+
allow:
|
|
175
|
+
- 192.168.0.0/16
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The browser asks once about the certificate; compare the fingerprint printed in
|
|
179
|
+
the log and accept it.
|
|
180
|
+
|
|
181
|
+
**What you lose compared with a real proxy.** Not much, and it is worth naming:
|
|
182
|
+
no HTTP/2, no gzip, no request logging beyond refusals, no rate limiting, no
|
|
183
|
+
virtual hosts. If you need any of those, keep the proxy — the plugin does not
|
|
184
|
+
mind sitting behind one, and that is exactly what `proxy` mode is.
|
|
185
|
+
|
|
186
|
+
**What you do not lose.** Settings and credentials over the network, WebSockets,
|
|
187
|
+
streamed replies of any length, TLS, the microphone.
|
|
188
|
+
|
|
189
|
+
**Check after switching**, in this order: the conversation opens and a reply
|
|
190
|
+
streams to the end; settings open and save; a long reply is not cut; the
|
|
191
|
+
microphone works on `/dsh-lanmode/health`.
|
|
192
|
+
|
|
135
193
|
## Who may connect
|
|
136
194
|
|
|
137
195
|
The direct listener has no password and will not get one: the plugin does not
|
|
@@ -156,6 +214,33 @@ Two honest limits. This is not authentication: whoever is on the list gets in
|
|
|
156
214
|
unchecked. And behind a reverse proxy it means nothing — every request arrives
|
|
157
215
|
from the proxy, so filter there instead.
|
|
158
216
|
|
|
217
|
+
## The price of it working
|
|
218
|
+
|
|
219
|
+
The harness pins its privileged calls — settings, credentials, agent presets,
|
|
220
|
+
opening paths on the machine, model discovery — to the loopback on purpose, and
|
|
221
|
+
no trusted-host list opens them. The bridge presents itself as a loopback
|
|
222
|
+
client, so those calls go through. That is not a side effect: without it a page
|
|
223
|
+
on the network shows the settings and can neither read nor write them, which is
|
|
224
|
+
the whole reason this plugin exists.
|
|
225
|
+
|
|
226
|
+
The price, stated plainly: **any device that reaches this port can read and
|
|
227
|
+
change your API keys, with no authentication at all.** Nothing here asks who you
|
|
228
|
+
are.
|
|
229
|
+
|
|
230
|
+
It is a setting, not a secret:
|
|
231
|
+
|
|
232
|
+
```yaml
|
|
233
|
+
unlockPrivileged: false # settings over the network stop working
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Off, the privileged calls are refused by the bridge with a message saying why,
|
|
237
|
+
and everything else keeps working. On — which is the default, because otherwise
|
|
238
|
+
the plugin does not do its job — the log says at startup exactly what is open,
|
|
239
|
+
and the diagnostics page shows it as a separate line.
|
|
240
|
+
|
|
241
|
+
Fill in `allow` either way. It is not authentication, but it narrows the circle
|
|
242
|
+
from «the whole network» to «these addresses».
|
|
243
|
+
|
|
159
244
|
## Diagnostics
|
|
160
245
|
|
|
161
246
|
`GET /dsh-lanmode/health` — one page answering the questions that otherwise take
|
|
@@ -197,6 +282,9 @@ All of it can be edited as the `dsh-lanmode` namespace — in `$DSH_HOME/setting
|
|
|
197
282
|
| `tlsCert` | — | `files`: path to the certificate in PEM |
|
|
198
283
|
| `tlsKey` | — | `files`: path to the private key in PEM |
|
|
199
284
|
| `allow` | `[]` | `direct`: addresses and CIDR ranges allowed in. Empty means everyone |
|
|
285
|
+
| `unlockPrivileged` | `true` | `direct`: let the settings and credentials calls through. What makes the plugin work, and what opens your keys to the network |
|
|
286
|
+
| `privilegedExtra` | `[]` | `direct`: extra path patterns to treat as privileged |
|
|
287
|
+
| `streamTimeoutMs` | `0` | `direct`: limit on one request. Zero means none, and that is what long replies need |
|
|
200
288
|
| `diagnostics` | `true` | serve `GET /dsh-lanmode/health` |
|
|
201
289
|
| `settings` | `true` | return the settings service |
|
|
202
290
|
| `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
|
package/lib/bind.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Выбор адреса для слушателя прямого режима.
|
|
2
|
+
//
|
|
3
|
+
// Отдельным модулем, потому что проверки не должны тянуть за собой харнесс:
|
|
4
|
+
// точка входа импортирует схему настроек, а её в рабочей копии нет.
|
|
5
|
+
|
|
6
|
+
import { localAddresses } from './tls.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* На каких адресах слушать.
|
|
10
|
+
*
|
|
11
|
+
* Харнесс уже занимает свой порт на петле, поэтому «все адреса» на том же порту
|
|
12
|
+
* не поднимутся вовсе: получится EADDRINUSE, и плагин молча не заработает. В
|
|
13
|
+
* этом случае привязываемся к сетевым адресам машины поимённо — привычный порт
|
|
14
|
+
* сохраняется, столкновения нет.
|
|
15
|
+
*/
|
|
16
|
+
export function bindAddresses(config, harnessPort) {
|
|
17
|
+
const wanted = String(config.directHost || '0.0.0.0')
|
|
18
|
+
const port = config.directPort || 3088
|
|
19
|
+
const everywhere = wanted === '0.0.0.0' || wanted === '::' || wanted === ''
|
|
20
|
+
if (!everywhere || port !== harnessPort) return { hosts: [wanted], shared: false }
|
|
21
|
+
|
|
22
|
+
const own = localAddresses().filter((address) => /^[0-9.]+$/.test(address) && !/^127\./.test(address))
|
|
23
|
+
if (own.length === 0) return { hosts: [wanted], shared: false }
|
|
24
|
+
return { hosts: own, shared: true }
|
|
25
|
+
}
|
package/lib/bridge.js
CHANGED
|
@@ -19,6 +19,7 @@ import http from 'node:http'
|
|
|
19
19
|
import https from 'node:https'
|
|
20
20
|
|
|
21
21
|
import { allowed } from './access.js'
|
|
22
|
+
import { REFUSED, isPrivileged } from './privileged.js'
|
|
22
23
|
|
|
23
24
|
function rewritten(headers, authority) {
|
|
24
25
|
const out = { ...headers, host: authority }
|
|
@@ -48,10 +49,27 @@ function throttle(log, everyMs) {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Снять ограничения на время.
|
|
54
|
+
*
|
|
55
|
+
* Ответ агента печатается минутами, а событийный поток живёт часами. Умолчания
|
|
56
|
+
* Node рассчитаны на обычные запросы и такое рвут — молча, посреди ответа, что
|
|
57
|
+
* снаружи выглядит как поломка харнесса. Обратный прокси перед харнессом
|
|
58
|
+
* настраивают ровно так же, иначе он не годится.
|
|
59
|
+
*/
|
|
60
|
+
function relaxTimeouts(server, streamTimeoutMs) {
|
|
61
|
+
server.timeout = streamTimeoutMs
|
|
62
|
+
server.requestTimeout = streamTimeoutMs
|
|
63
|
+
server.headersTimeout = streamTimeoutMs || 0
|
|
64
|
+
server.keepAliveTimeout = streamTimeoutMs || 72000
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
/**
|
|
52
68
|
* @param ctx контекст плагина (нужен ctx.webServer.port)
|
|
53
|
-
* @param options {{
|
|
54
|
-
* allow?: object[], tls?: {cert: string, key: string}
|
|
69
|
+
* @param options {{hosts: string[], port: number, log: (message: string) => void,
|
|
70
|
+
* allow?: object[], tls?: {cert: string, key: string},
|
|
71
|
+
* unlockPrivileged?: boolean, privilegedExtra?: string[],
|
|
72
|
+
* streamTimeoutMs?: number}}
|
|
55
73
|
* @returns функция остановки
|
|
56
74
|
*/
|
|
57
75
|
export function startDirectBridge(ctx, options) {
|
|
@@ -63,6 +81,9 @@ export function startDirectBridge(ctx, options) {
|
|
|
63
81
|
const authority = '127.0.0.1:' + upstreamPort
|
|
64
82
|
const rules = options.allow ?? []
|
|
65
83
|
const refuse = throttle(options.log, 10000)
|
|
84
|
+
const streamTimeoutMs = options.streamTimeoutMs ?? 0
|
|
85
|
+
const unlocked = options.unlockPrivileged !== false
|
|
86
|
+
const hosts = options.hosts && options.hosts.length ? options.hosts : ['0.0.0.0']
|
|
66
87
|
|
|
67
88
|
/** Пускать ли этого гостя; отказ пишется в журнал не чаще раза в десять секунд. */
|
|
68
89
|
const welcome = (address) => {
|
|
@@ -77,13 +98,26 @@ export function startDirectBridge(ctx, options) {
|
|
|
77
98
|
res.end('forbidden')
|
|
78
99
|
return
|
|
79
100
|
}
|
|
101
|
+
if (!unlocked && isPrivileged(req.url, options.privilegedExtra)) {
|
|
102
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
103
|
+
res.end(REFUSED)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Ответ идёт кусками столько, сколько нужно: ни своих ограничений, ни
|
|
108
|
+
// накопления в памяти.
|
|
109
|
+
req.setTimeout(streamTimeoutMs)
|
|
110
|
+
res.setTimeout(streamTimeoutMs)
|
|
111
|
+
|
|
80
112
|
const upstream = http.request({
|
|
81
113
|
host: '127.0.0.1',
|
|
82
114
|
port: upstreamPort,
|
|
83
115
|
method: req.method,
|
|
84
116
|
path: req.url,
|
|
85
117
|
headers: rewritten(req.headers, authority),
|
|
118
|
+
timeout: streamTimeoutMs || undefined,
|
|
86
119
|
}, (answer) => {
|
|
120
|
+
answer.setTimeout(streamTimeoutMs)
|
|
87
121
|
res.writeHead(answer.statusCode || 502, answer.headers)
|
|
88
122
|
answer.pipe(res)
|
|
89
123
|
})
|
|
@@ -94,14 +128,7 @@ export function startDirectBridge(ctx, options) {
|
|
|
94
128
|
req.pipe(upstream)
|
|
95
129
|
}
|
|
96
130
|
|
|
97
|
-
const
|
|
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) => {
|
|
131
|
+
const upgrade = (req, socket, head) => {
|
|
105
132
|
if (!welcome(socket.remoteAddress)) {
|
|
106
133
|
try {
|
|
107
134
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
@@ -109,6 +136,11 @@ export function startDirectBridge(ctx, options) {
|
|
|
109
136
|
} catch (already) { /* уже мертво */ }
|
|
110
137
|
return
|
|
111
138
|
}
|
|
139
|
+
// Событийный поток живёт часами и молчит между событиями: любой таймаут на
|
|
140
|
+
// этом сокете рано или поздно оборвёт разговор с агентом.
|
|
141
|
+
socket.setTimeout(0)
|
|
142
|
+
socket.setNoDelay(true)
|
|
143
|
+
|
|
112
144
|
const upstream = http.request({
|
|
113
145
|
host: '127.0.0.1',
|
|
114
146
|
port: upstreamPort,
|
|
@@ -117,6 +149,8 @@ export function startDirectBridge(ctx, options) {
|
|
|
117
149
|
headers: rewritten(req.headers, authority),
|
|
118
150
|
})
|
|
119
151
|
upstream.on('upgrade', (answer, upstreamSocket, upstreamHead) => {
|
|
152
|
+
upstreamSocket.setTimeout(0)
|
|
153
|
+
upstreamSocket.setNoDelay(true)
|
|
120
154
|
const lines = ['HTTP/1.1 101 Switching Protocols']
|
|
121
155
|
for (const [key, value] of Object.entries(answer.headers)) lines.push(key + ': ' + value)
|
|
122
156
|
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
@@ -130,17 +164,42 @@ export function startDirectBridge(ctx, options) {
|
|
|
130
164
|
upstream.on('error', () => { try { socket.destroy() } catch (already) { /* уже мертво */ } })
|
|
131
165
|
if (head && head.length) upstream.write(head)
|
|
132
166
|
upstream.end()
|
|
133
|
-
}
|
|
167
|
+
}
|
|
134
168
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
const servers = []
|
|
170
|
+
for (const host of hosts) {
|
|
171
|
+
const server = options.tls
|
|
172
|
+
? https.createServer({ cert: options.tls.cert, key: options.tls.key }, handle)
|
|
173
|
+
: http.createServer(handle)
|
|
174
|
+
server.on('upgrade', upgrade)
|
|
175
|
+
relaxTimeouts(server, streamTimeoutMs)
|
|
138
176
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
177
|
+
server.on('error', (failure) => {
|
|
178
|
+
const code = failure && failure.code
|
|
179
|
+
if (code === 'EADDRINUSE') {
|
|
180
|
+
options.log('порт ' + options.port + ' на адресе ' + host + ' уже занят. '
|
|
181
|
+
+ 'Либо там уже кто-то обслуживает сеть, либо порт держит посторонний: '
|
|
182
|
+
+ 'смените directPort или освободите его')
|
|
183
|
+
} else if (code === 'EADDRNOTAVAIL') {
|
|
184
|
+
options.log('адрес ' + host + ' на этой машине не поднят — пропускаю его')
|
|
185
|
+
} else {
|
|
186
|
+
options.log('прямой режим не поднялся на ' + host + ': '
|
|
187
|
+
+ String(failure && failure.message || failure))
|
|
188
|
+
}
|
|
189
|
+
})
|
|
144
190
|
|
|
145
|
-
|
|
191
|
+
server.listen(options.port, host, () => {
|
|
192
|
+
options.log('слушаю ' + (options.tls ? 'https://' : 'http://') + host + ':' + options.port
|
|
193
|
+
+ ', передаю на ' + authority
|
|
194
|
+
+ (rules.length ? ', пускаю ' + rules.length + ' правил(о) из списка' : ', пускаю всех')
|
|
195
|
+
+ (unlocked ? '' : ', привилегированные вызовы закрыты'))
|
|
196
|
+
})
|
|
197
|
+
servers.push(server)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
for (const server of servers) {
|
|
202
|
+
try { server.close() } catch (already) { /* уже закрыт */ }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
146
205
|
}
|
package/lib/health.js
CHANGED
|
@@ -20,6 +20,7 @@ export function hostReport(state) {
|
|
|
20
20
|
modeReason: state.modeReason ?? '',
|
|
21
21
|
listener: state.listener ?? null,
|
|
22
22
|
tls: state.tls ?? { enabled: false },
|
|
23
|
+
unlockPrivileged: state.unlockPrivileged ?? null,
|
|
23
24
|
pieces: state.pieces,
|
|
24
25
|
allow: state.allow ?? [],
|
|
25
26
|
assumptions: state.assumptions ?? [],
|
|
@@ -39,19 +40,34 @@ const BROWSER_SCRIPT = `
|
|
|
39
40
|
'интерфейс зовёт его при загрузке; на голом HTTP его подставляет заплатка'],
|
|
40
41
|
['буфер обмена', !!(navigator.clipboard && navigator.clipboard.writeText),
|
|
41
42
|
'кнопки «копировать»; на голом HTTP его подставляет заплатка'],
|
|
42
|
-
|
|
43
|
+
// Проверять наличие заплатки на ЭТОЙ странице бессмысленно: она вставляется
|
|
44
|
+
// в страницу интерфейса, а не в нашу, и ответ был бы всегда «нет» — ложная
|
|
45
|
+
// тревога того сорта, из-за которой потом ищут несуществующую поломку.
|
|
46
|
+
// Поэтому спрашиваем ту страницу, куда она и вставляется.
|
|
47
|
+
['заплатка на странице интерфейса', 'проверяется',
|
|
43
48
|
'если нет — скрипт не попал на страницу или его выключили через ?lanmode=off'],
|
|
44
49
|
['страница считается своей', /^(localhost|\\[::1\\]|::1|127\\.)/.test(location.hostname),
|
|
45
50
|
'на чужом имени харнесс уводит настройки в режим памяти; это и чинит заплатка']
|
|
46
51
|
]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
function draw() {
|
|
53
|
+
out.innerHTML = '<table>' + checks.map(function (item) {
|
|
54
|
+
var mark = item[1] === 'проверяется' ? '…' : (item[1] ? '✔' : '✘')
|
|
55
|
+
return '<tr><td>' + mark + '</td><td>' + item[0] + '</td><td>'
|
|
56
|
+
+ (item[1] === true || item[1] === 'проверяется' ? '' : item[2]) + '</td></tr>'
|
|
57
|
+
}).join('') + '</table>'
|
|
58
|
+
window.__DSH_LANMODE_BROWSER__ = checks.map(function (item) {
|
|
59
|
+
return { name: item[0], ok: item[1] }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
draw()
|
|
63
|
+
|
|
64
|
+
// Страницу интерфейса спрашиваем отдельно и дорисовываем ответ: она приходит
|
|
65
|
+
// не мгновенно, а держать из-за неё всю таблицу незачем.
|
|
66
|
+
var at = checks.findIndex(function (item) { return item[0].indexOf('заплатка') === 0 })
|
|
67
|
+
fetch('/', { cache: 'no-store' })
|
|
68
|
+
.then(function (answer) { return answer.text() })
|
|
69
|
+
.then(function (html) { checks[at][1] = html.indexOf('data-dsh-lanmode') !== -1; draw() })
|
|
70
|
+
.catch(function () { checks[at][1] = false; draw() })
|
|
55
71
|
})()
|
|
56
72
|
`
|
|
57
73
|
|
|
@@ -71,8 +87,14 @@ function escapeHtml(text) {
|
|
|
71
87
|
export function healthPage(state) {
|
|
72
88
|
const report = hostReport(state)
|
|
73
89
|
const listener = report.listener
|
|
74
|
-
? escapeHtml(report.listener.
|
|
90
|
+
? escapeHtml(report.listener.hosts.map((host) => report.listener.scheme + '://' + host
|
|
91
|
+
+ ':' + report.listener.port).join(', '))
|
|
75
92
|
: 'нет — сеть обслуживает кто-то другой'
|
|
93
|
+
const privileged = report.unlockPrivileged === null
|
|
94
|
+
? 'не применимо в этом режиме'
|
|
95
|
+
: (report.unlockPrivileged
|
|
96
|
+
? 'ОТКРЫТЫ: настройки и учётные данные доступны любому, кто дотянулся до порта'
|
|
97
|
+
: 'закрыты: настройки по сети не читаются и не пишутся')
|
|
76
98
|
|
|
77
99
|
return '<!doctype html><html lang="ru"><head><meta charset="utf-8">'
|
|
78
100
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
@@ -94,6 +116,7 @@ export function healthPage(state) {
|
|
|
94
116
|
: 'нет') + '</dd>'
|
|
95
117
|
+ '<dt>подменено</dt><dd>' + escapeHtml(Object.entries(report.pieces)
|
|
96
118
|
.filter(([, on]) => on).map(([name]) => name).join(', ') || 'ничего') + '</dd>'
|
|
119
|
+
+ '<dt>привилегированные вызовы</dt><dd>' + escapeHtml(privileged) + '</dd>'
|
|
97
120
|
+ '<dt>пускаю</dt><dd>' + escapeHtml(report.allow.length ? report.allow.join(', ') : 'всех') + '</dd>'
|
|
98
121
|
+ '</dl>'
|
|
99
122
|
+ '<h2>Точки крепления</h2><table>' + report.assumptions.map(row).join('') + '</table>'
|
package/lib/index.js
CHANGED
|
@@ -35,7 +35,9 @@ import { fileURLToPath } from 'node:url'
|
|
|
35
35
|
|
|
36
36
|
import { parseAllow } from './access.js'
|
|
37
37
|
import { checkAssumptions, summarize } from './assumptions.js'
|
|
38
|
+
import { bindAddresses } from './bind.js'
|
|
38
39
|
import { startDirectBridge } from './bridge.js'
|
|
40
|
+
import { isPrivileged } from './privileged.js'
|
|
39
41
|
import { healthPage, hostReport } from './health.js'
|
|
40
42
|
import { detectMode } from './mode.js'
|
|
41
43
|
import { ensureCertificate, localAddresses, readCertificate } from './tls.js'
|
|
@@ -106,6 +108,27 @@ export const Config = z.object({
|
|
|
106
108
|
+ 'circle, nothing more. Behind a reverse proxy it is meaningless — every request arrives from '
|
|
107
109
|
+ 'the proxy.')
|
|
108
110
|
.default([]),
|
|
111
|
+
unlockPrivileged: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.description('mode=direct: let the settings, credentials, agent-preset, path-opening and '
|
|
114
|
+
+ 'model-discovery calls through. The harness pins those to the loopback on purpose, and the '
|
|
115
|
+
+ 'bridge lifts that pin because without it a page on the network can show settings but '
|
|
116
|
+
+ 'neither read nor write them — which is the whole point of this plugin. '
|
|
117
|
+
+ 'The price, stated plainly: any device that reaches this port can read and change your API '
|
|
118
|
+
+ 'keys with no authentication whatsoever. Turn it off for a network you do not trust, and '
|
|
119
|
+
+ 'fill in "allow" either way.')
|
|
120
|
+
.default(true),
|
|
121
|
+
privilegedExtra: z
|
|
122
|
+
.array(z.string())
|
|
123
|
+
.description('mode=direct: extra path patterns to treat as privileged, for plugins that add '
|
|
124
|
+
+ 'their own loopback-pinned calls. Regular expressions over the request path.')
|
|
125
|
+
.default([]),
|
|
126
|
+
streamTimeoutMs: z
|
|
127
|
+
.number()
|
|
128
|
+
.description('mode=direct: how long a single request may take. Zero means no limit, and that is '
|
|
129
|
+
+ 'the default: an agent reply is printed over minutes and the event stream lives for hours, '
|
|
130
|
+
+ 'while Node cuts both by default.')
|
|
131
|
+
.default(0),
|
|
109
132
|
diagnostics: z
|
|
110
133
|
.boolean()
|
|
111
134
|
.description('Serve GET /dsh-lanmode/health: what is patched, which mode is on, and what the '
|
|
@@ -190,8 +213,11 @@ function certificateDir(config) {
|
|
|
190
213
|
|
|
191
214
|
/** Поднять слушатель прямого режима: сначала сертификат, потом мост. */
|
|
192
215
|
async function raiseListener(ctx, config, state) {
|
|
193
|
-
const host = config.directHost || '0.0.0.0'
|
|
194
216
|
const port = config.directPort || 3088
|
|
217
|
+
const { hosts, shared } = bindAddresses(config, ctx.webServer && ctx.webServer.port)
|
|
218
|
+
if (shared) {
|
|
219
|
+
say('порт ' + port + ' занят харнессом на петле, поэтому слушаю поимённо: ' + hosts.join(', '))
|
|
220
|
+
}
|
|
195
221
|
const { rules, dropped } = parseAllow(config.allow)
|
|
196
222
|
if (dropped.length) say('в списке разрешённых не разобраны записи: ' + dropped.join(', '))
|
|
197
223
|
|
|
@@ -226,8 +252,28 @@ async function raiseListener(ctx, config, state) {
|
|
|
226
252
|
}
|
|
227
253
|
}
|
|
228
254
|
|
|
229
|
-
|
|
230
|
-
|
|
255
|
+
const unlocked = config.unlockPrivileged !== false
|
|
256
|
+
if (unlocked) {
|
|
257
|
+
say('ВНИМАНИЕ: привилегированные вызовы открыты для сети. Настройки, учётные данные '
|
|
258
|
+
+ 'и открытие путей на этой машине доступны ЛЮБОМУ, кто дотянется до порта ' + port
|
|
259
|
+
+ ', без всякой проверки. Так плагин и делает свою работу, но знать об этом надо.')
|
|
260
|
+
if (rules.length === 0) {
|
|
261
|
+
say('список разрешённых адресов пуст. Заполните allow — это не пароль, но круг сузит.')
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
state.listener = { scheme: tls ? 'https' : 'http', hosts, port }
|
|
266
|
+
state.unlockPrivileged = unlocked
|
|
267
|
+
return startDirectBridge(ctx, {
|
|
268
|
+
hosts,
|
|
269
|
+
port,
|
|
270
|
+
log: say,
|
|
271
|
+
allow: rules,
|
|
272
|
+
tls,
|
|
273
|
+
unlockPrivileged: unlocked,
|
|
274
|
+
privilegedExtra: config.privilegedExtra,
|
|
275
|
+
streamTimeoutMs: config.streamTimeoutMs || 0,
|
|
276
|
+
})
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
function start(ctx, config) {
|
|
@@ -242,6 +288,7 @@ function start(ctx, config) {
|
|
|
242
288
|
mode: config.mode,
|
|
243
289
|
modeReason: '',
|
|
244
290
|
listener: null,
|
|
291
|
+
unlockPrivileged: null,
|
|
245
292
|
tls: { enabled: false },
|
|
246
293
|
pieces,
|
|
247
294
|
allow: Array.isArray(config.allow) ? config.allow : [],
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Привилегированные вызовы харнесса.
|
|
2
|
+
//
|
|
3
|
+
// Часть методов ядро намеренно держит на петле: настройки, учётные данные,
|
|
4
|
+
// наборы агента, открытие путей на машине, опрос моделей. Забор у них строже
|
|
5
|
+
// обычного — он пропускает только клиента, пришедшего с петли, и никакой
|
|
6
|
+
// список доверенных адресов его не открывает.
|
|
7
|
+
//
|
|
8
|
+
// Мост переписывает Host и Origin на петлю, поэтому для харнесса он и есть
|
|
9
|
+
// клиент с петли. Побочное следствие: привилегированные вызовы открываются
|
|
10
|
+
// тоже. Это и делает прямой режим полноценным — без этого страница по сети
|
|
11
|
+
// показывает настройки, но не может ни прочитать их, ни записать.
|
|
12
|
+
//
|
|
13
|
+
// Плата названа прямо: любое устройство, дотянувшееся до порта, читает и меняет
|
|
14
|
+
// учётные данные без всякой проверки. Поэтому обход управляется отдельной
|
|
15
|
+
// настройкой, о нём говорится при запуске и он виден на странице диагностики.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Пути, которые ядро держит на петле.
|
|
19
|
+
*
|
|
20
|
+
* Список ведётся по именам методов, а не по догадкам: каждый из них меняет либо
|
|
21
|
+
* настройки, либо ключи, либо трогает файловую систему машины.
|
|
22
|
+
*/
|
|
23
|
+
export const PRIVILEGED = [
|
|
24
|
+
/^\/api\/settings\.(describe|openDocument|update|replace|mutate)$/,
|
|
25
|
+
/^\/api\/credentials\.(describe|set|unset)$/,
|
|
26
|
+
/^\/api\/agentPreset\.(read|copy|openDocument|remove)$/,
|
|
27
|
+
/^\/api\/host\.(pickDirectory|openPath)$/,
|
|
28
|
+
/^\/api\/llm\.discoverModels$/,
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** Привилегированный ли это путь. */
|
|
32
|
+
export function isPrivileged(url, extra) {
|
|
33
|
+
const path = String(url ?? '').split('?')[0]
|
|
34
|
+
if (!path) return false
|
|
35
|
+
for (const rule of PRIVILEGED) if (rule.test(path)) return true
|
|
36
|
+
for (const rule of extra ?? []) {
|
|
37
|
+
try {
|
|
38
|
+
if (new RegExp(rule).test(path)) return true
|
|
39
|
+
} catch (badPattern) {
|
|
40
|
+
// Кривое выражение в настройках не должно ронять мост: считаем, что
|
|
41
|
+
// такого правила нет, а о разборе жалуется тот, кто их читает.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Что сказать человеку, когда обход выключен, а вызов пришёл. */
|
|
48
|
+
export const REFUSED = 'dsh-lanmode: этот вызов ядро держит на петле. '
|
|
49
|
+
+ 'Включите unlockPrivileged, если сознательно открываете настройки и ключи сети.'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-lanmode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|