@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 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 {{host: string, port: number, log: (message: string) => void,
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 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) => {
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
- bridge.on('error', (failure) => {
136
- options.log('прямой режим не поднялся: ' + String(failure && failure.message || failure))
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
- 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
- })
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
- return () => { bridge.close() }
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
- ['заплатка отработала', !!shim,
43
+ // Проверять наличие заплатки на ЭТОЙ странице бессмысленно: она вставляется
44
+ // в страницу интерфейса, а не в нашу, и ответ был бы всегда «нет» — ложная
45
+ // тревога того сорта, из-за которой потом ищут несуществующую поломку.
46
+ // Поэтому спрашиваем ту страницу, куда она и вставляется.
47
+ ['заплатка на странице интерфейса', 'проверяется',
43
48
  'если нет — скрипт не попал на страницу или его выключили через ?lanmode=off'],
44
49
  ['страница считается своей', /^(localhost|\\[::1\\]|::1|127\\.)/.test(location.hostname),
45
50
  'на чужом имени харнесс уводит настройки в режим памяти; это и чинит заплатка']
46
51
  ]
47
- var rows = checks.map(function (item) {
48
- return '<tr><td>' + (item[1] ? '✔' : '✘') + '</td><td>' + item[0] + '</td><td>'
49
- + (item[1] ? '' : item[2]) + '</td></tr>'
50
- }).join('')
51
- out.innerHTML = '<table>' + rows + '</table>'
52
- window.__DSH_LANMODE_BROWSER__ = checks.map(function (item) {
53
- return { name: item[0], ok: item[1] }
54
- })
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.scheme + '://' + report.listener.host + ':' + report.listener.port)
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
- state.listener = { scheme: tls ? 'https' : 'http', host, port }
230
- return startDirectBridge(ctx, { host, port, log: say, allow: rules, tls })
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.5.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",