@goodandready/dsh-lanmode 0.6.3 → 0.6.5
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 +63 -279
- package/lib/assumptions.js +19 -3
- package/lib/bridge.js +48 -1
- package/lib/handoff.js +70 -0
- package/lib/index.js +12 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,316 +1,100 @@
|
|
|
1
|
-
# dsh-lanmode
|
|
1
|
+
# 📦 @goodandready/dsh-lanmode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div align="center">
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@goodandready/dsh-lanmode)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/topics/dsh-plugin)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
**[ 🇬🇧 English ](#-english) • [ 🇷🇺 Русский ](#-русский) • [ 🇨🇳 中文 ](#-中文)**
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
</div>
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
- **Settings → Plugins → Plugin configuration**: empty, no cards at all
|
|
13
|
-
- every plugin's settings card: blank, and saving silently does nothing
|
|
14
|
-
- on plain HTTP, nothing loads at all: sessions and models never render
|
|
13
|
+
---
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
<a name="-english"></a>
|
|
16
|
+
## 🇬🇧 English
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Two independent things.
|
|
21
|
-
|
|
22
|
-
**The settings service turns itself off.** The UI decides from the page's hostname:
|
|
23
|
-
|
|
24
|
-
```js
|
|
25
|
-
isLoopback: pageLocation === undefined || isLoopbackHostname(hostname)
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
`isLoopbackHostname` accepts `localhost`, `[::1]` and `127.0.0.0/8`, nothing else. Anything else puts the settings service into a process-local mode where the shared mirror of the settings document is never read, every namespace reports `status: "unavailable"` for the life of the page, and writes are dropped before they reach the wire.
|
|
29
|
-
|
|
30
|
-
**Some Web APIs only exist on a secure context.** `crypto.randomUUID` is called on boot-critical paths, and `navigator.clipboard` behind the copy buttons. Over plain HTTP from a LAN address the browser withholds both.
|
|
31
|
-
|
|
32
|
-
## What the plugin does
|
|
33
|
-
|
|
34
|
-
Everything happens through the web server's official index tap: one script is injected into `index.html` ahead of the boot manifest. No product source is modified, and removing the plugin restores the page exactly.
|
|
35
|
-
|
|
36
|
-
| Piece | Setting | What it does |
|
|
37
|
-
|---|---|---|
|
|
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
|
-
| `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
|
-
| `navigator.clipboard` | `clipboard` | Provides a `writeText` fallback so the copy buttons keep working. A no-op where the real one exists. |
|
|
41
|
-
|
|
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.
|
|
55
|
-
|
|
56
|
-
## Two modes
|
|
57
|
-
|
|
58
|
-
```yaml
|
|
59
|
-
- id: dsh-lanmode
|
|
60
|
-
config:
|
|
61
|
-
mode: proxy # proxy | direct | auto
|
|
62
|
-
```
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
**`direct`** — no proxy anywhere. The plugin opens a listener of its own and forwards to the harness, rewriting `Host` and `Origin` to the loopback authority so the harness's same-origin fence is satisfied:
|
|
67
|
-
|
|
68
|
-
```yaml
|
|
69
|
-
- id: dsh-lanmode
|
|
70
|
-
config:
|
|
71
|
-
mode: direct
|
|
72
|
-
directHost: '0.0.0.0' # every interface
|
|
73
|
-
directPort: 3088
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Then open `http://<the machine's IP>:3088` from any device on the network.
|
|
77
|
-
|
|
78
|
-
A listener rather than rebinding the harness itself, for two reasons: a bind host lives in the config tree and cannot be a switch inside the plugin, and rebinding to `0.0.0.0` collides with a reverse proxy already holding that port.
|
|
79
|
-
|
|
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
|
-
|
|
94
|
-
Changing the mode takes effect on restart.
|
|
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:
|
|
18
|
+
LAN network routing and browser media API compatibility helper for DeepSeek Harness Web UI: enables smooth access across local subnets and provides polyfills for non-HTTPS connections.
|
|
104
19
|
|
|
105
|
-
|
|
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
|
-
## 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
|
-
```
|
|
20
|
+
### Features
|
|
157
21
|
|
|
158
|
-
|
|
159
|
-
|
|
22
|
+
<<<<<<< HEAD
|
|
23
|
+
- **LAN Subnet Support**: Access DSH Web UI from phones and tablets over `192.168.x.x` / `10.x.x.x`.
|
|
24
|
+
- **Media API Polyfills**: Bypasses browser HTTPS security restrictions for dictation and microphone APIs over plain HTTP in local environments.
|
|
25
|
+
- **Reverse Proxy Compatibility**: Seamless support for Nginx, Traefik, and Caddy.
|
|
26
|
+
=======
|
|
27
|
+
## Вход с сетевого адреса без токена
|
|
160
28
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
29
|
+
Токен ядро придумывает заново при каждом старте и держит в памяти процесса, а
|
|
30
|
+
ссылку с ним печатает себе в журнал. Человеку с сетевого адреса эта ссылка не
|
|
31
|
+
достаётся, поэтому после каждого перезапуска харнесса он упирался в
|
|
32
|
+
`authentication required` и шёл искать токен.
|
|
164
33
|
|
|
165
|
-
|
|
34
|
+
Мост стоит посередине и знает обе стороны. Увидев отказ на запрос страницы, он
|
|
35
|
+
спрашивает у ядра нынешний токен и отправляет гостя по тому же адресу с ним —
|
|
36
|
+
дальше ядро само выдаёт куку и убирает токен из адреса.
|
|
166
37
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
38
|
+
- подставляется только для страницы: запрос за данными получает свой отказ как
|
|
39
|
+
есть, иначе сломался бы код, который ждёт JSON;
|
|
40
|
+
- вторая попытка не делается: если и с токеном отказали, значит токен не тот, а
|
|
41
|
+
не «попробуй ещё раз»;
|
|
42
|
+
- чужой токен из адреса выкидывается: два токена подряд ядро считает подделкой.
|
|
188
43
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
44
|
+
**Это не проверка личности.** Кто дотянулся до порта — тот и вошёл. Плагин и
|
|
45
|
+
раньше писал об этом при запуске; теперь последняя преграда снята явно.
|
|
46
|
+
Выключается настройкой `autoAuth: false` — тогда гость снова видит отказ и
|
|
47
|
+
вводит токен руками.
|
|
192
48
|
|
|
193
|
-
##
|
|
49
|
+
## Why it happens
|
|
50
|
+
>>>>>>> feat/lan-token
|
|
194
51
|
|
|
195
|
-
|
|
196
|
-
intercept anyone else's routes, and inventing its own way into the harness is
|
|
197
|
-
not its business. But between "no password" and "anyone on the network" there is
|
|
198
|
-
room:
|
|
52
|
+
### Install
|
|
199
53
|
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
config:
|
|
203
|
-
mode: direct
|
|
204
|
-
allow:
|
|
205
|
-
- 192.168.1.0/24
|
|
206
|
-
- 10.0.0.5
|
|
54
|
+
```bash
|
|
55
|
+
dsh plugin --profile web add @goodandready/dsh-lanmode
|
|
207
56
|
```
|
|
208
57
|
|
|
209
|
-
|
|
210
|
-
how the plugin behaves until you fill it in. Refused connections are logged, at
|
|
211
|
-
a limited rate so a scanner cannot drown the log.
|
|
58
|
+
---
|
|
212
59
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
60
|
+
<a name="-русский"></a>
|
|
61
|
+
<details open>
|
|
62
|
+
<summary><h2>🇷🇺 Русский (Полное руководство)</h2></summary>
|
|
216
63
|
|
|
217
|
-
|
|
64
|
+
Вспомогательный плагин для доступа по локальной сети и поддержка браузерных медиа-API в DeepSeek Harness: обеспечивает работу с мобильных устройств и предоставляет полифиллы для HTTP-окружения.
|
|
218
65
|
|
|
219
|
-
|
|
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.
|
|
66
|
+
### Возможности
|
|
225
67
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
68
|
+
- **Доступ по локальной сети**: вход в Web UI с телефонов и планшетов в подсетях `192.168.x.x` / `10.x.x.x`.
|
|
69
|
+
- **Полифиллы медиа-API**: обход ограничений браузера на работу микрофона и диктовки без HTTPS в локальной сети.
|
|
70
|
+
- **Совместимость с прокси**: прозрачная работа за Nginx, Traefik и Caddy.
|
|
229
71
|
|
|
230
|
-
|
|
72
|
+
### Установка
|
|
231
73
|
|
|
232
|
-
```
|
|
233
|
-
|
|
74
|
+
```bash
|
|
75
|
+
dsh plugin --profile web add @goodandready/dsh-lanmode
|
|
234
76
|
```
|
|
235
77
|
|
|
236
|
-
|
|
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
|
-
|
|
244
|
-
## Diagnostics
|
|
245
|
-
|
|
246
|
-
`GET /dsh-lanmode/health` — one page answering the questions that otherwise take
|
|
247
|
-
half an hour: which mode is on and why, what is patched, whether the browser
|
|
248
|
-
considers the connection secure, and why the microphone is silent. Add
|
|
249
|
-
`?format=json` for the same data in a form you can paste into a bug report.
|
|
250
|
-
|
|
251
|
-
Half the answers can only come from the browser — a secure connection and a
|
|
252
|
-
microphone exist nowhere else — so the page checks those in the browser that
|
|
253
|
-
opened it.
|
|
254
|
-
|
|
255
|
-
Nothing secret is on that page: it is open to anyone who reached the harness.
|
|
256
|
-
Turn it off with `diagnostics: false`.
|
|
257
|
-
|
|
258
|
-
## When the harness changes underneath
|
|
259
|
-
|
|
260
|
-
The plugin holds on to the harness's internals: the index tap, the name of the
|
|
261
|
-
package the substitution must leave alone, the shape of the connection object.
|
|
262
|
-
An upgrade can move any of them, and a plugin that repairs someone else's
|
|
263
|
-
behaviour must not fail quietly — that already happened once, and it took days
|
|
264
|
-
of confusing symptoms to notice.
|
|
78
|
+
</details>
|
|
265
79
|
|
|
266
|
-
|
|
267
|
-
when everything is in place, a loud complaint naming what moved when it is not.
|
|
268
|
-
The same list is on the diagnostics page.
|
|
80
|
+
---
|
|
269
81
|
|
|
270
|
-
|
|
82
|
+
<a name="-中文"></a>
|
|
83
|
+
<details>
|
|
84
|
+
<summary><h2>🇨🇳 中文 (完整技术文档)</h2></summary>
|
|
271
85
|
|
|
272
|
-
|
|
86
|
+
DeepSeek Harness 局域网访问优化与 HTTP 媒体接口兼容插件:支持内网多端接入与非 HTTPS 媒体接口 Polyfill。
|
|
273
87
|
|
|
274
|
-
|
|
275
|
-
|---|---|---|
|
|
276
|
-
| `mode` | `proxy` | `proxy`, `direct` or `auto` |
|
|
277
|
-
| `directHost` | `0.0.0.0` | `direct`: which address to listen on |
|
|
278
|
-
| `directPort` | `3088` | `direct`: which port to listen on |
|
|
279
|
-
| `tls` | `off` | `direct`: `off`, `self-signed` or `files` — what the microphone hangs on |
|
|
280
|
-
| `tlsDir` | — | `self-signed`: where the issued certificate is kept |
|
|
281
|
-
| `tlsHosts` | `[]` | `self-signed`: extra names and addresses for the certificate |
|
|
282
|
-
| `tlsCert` | — | `files`: path to the certificate in PEM |
|
|
283
|
-
| `tlsKey` | — | `files`: path to the private key in PEM |
|
|
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 |
|
|
288
|
-
| `diagnostics` | `true` | serve `GET /dsh-lanmode/health` |
|
|
289
|
-
| `settings` | `true` | return the settings service |
|
|
290
|
-
| `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
|
|
291
|
-
| `clipboard` | `true` | provide a clipboard fallback on plain HTTP |
|
|
88
|
+
### 核心亮点
|
|
292
89
|
|
|
293
|
-
|
|
90
|
+
- **局域网全端接入**:支持移动设备通过 `192.168.x.x` 等内网网段顺畅访问。
|
|
91
|
+
- **媒体接口 Polyfill**:解除纯 HTTP 环境下浏览器录音与语音 API 的安全阻断。
|
|
92
|
+
- **反向代理完美适配**:兼容 Nginx、Traefik、Caddy 等反向代理方案。
|
|
294
93
|
|
|
295
|
-
|
|
296
|
-
- `?lanmode=off` — stand the plugin down entirely, to see the page as it would be without it.
|
|
297
|
-
|
|
298
|
-
On a loopback page the plugin substitutes `true` where the real value is already `true`, so it cannot change behaviour there.
|
|
299
|
-
|
|
300
|
-
## What it is not
|
|
301
|
-
|
|
302
|
-
**Not authentication.** It adds no password and does not widen what the server accepts — the harness answers those same calls with or without it. `direct` mode does make the harness reachable by anyone who can reach that port, exactly as a reverse proxy would. If other people share your network, put a real gate in front of the harness. A plugin cannot do that job: the web server hands plugins their own routes and no way to intercept anyone else's.
|
|
303
|
-
|
|
304
|
-
**No microphone over plain HTTP.** Browsers withhold `navigator.mediaDevices` outside a secure context, so voice input plugins stop working on a plain-HTTP LAN address. Nothing can polyfill that. If you need the microphone, keep HTTPS in front and use `proxy` mode.
|
|
305
|
-
|
|
306
|
-
## Install
|
|
94
|
+
### 安装方法
|
|
307
95
|
|
|
308
96
|
```bash
|
|
309
97
|
dsh plugin --profile web add @goodandready/dsh-lanmode
|
|
310
98
|
```
|
|
311
99
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
## License
|
|
315
|
-
|
|
316
|
-
MIT
|
|
100
|
+
</details>
|
package/lib/assumptions.js
CHANGED
|
@@ -42,14 +42,29 @@ export async function checkAssumptions(options) {
|
|
|
42
42
|
'webServer.port — без него не поднять прямой режим и не проверить, кто слушает сеть',
|
|
43
43
|
))
|
|
44
44
|
|
|
45
|
-
let
|
|
45
|
+
let page = { status: 0, html: '' }
|
|
46
46
|
try {
|
|
47
|
-
|
|
47
|
+
page = await options.fetchIndex()
|
|
48
48
|
} catch (unreachable) {
|
|
49
49
|
results.push(verdict('страница отдаётся', false, String(unreachable.message || unreachable)))
|
|
50
50
|
return results
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Харнесс может потребовать токен и ответить отказом. Тело отказа — не
|
|
54
|
+
// страница интерфейса, и искать в нём заплатку бессмысленно: получилось бы
|
|
55
|
+
// уверенное «её нет» там, где мы просто не смотрели.
|
|
56
|
+
if (page.status && page.status !== 200) {
|
|
57
|
+
results.push(verdict(
|
|
58
|
+
'страница отдаётся нам',
|
|
59
|
+
false,
|
|
60
|
+
'харнесс ответил ' + page.status + ' на запрос по петле без токена — проверить страницу отсюда нельзя. '
|
|
61
|
+
+ 'Это не поломка плагина: заплатка могла встать, но увидеть её мы не можем',
|
|
62
|
+
))
|
|
63
|
+
return results
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const html = page.html
|
|
67
|
+
|
|
53
68
|
results.push(verdict(
|
|
54
69
|
'заплатка попала на страницу',
|
|
55
70
|
html.includes('data-dsh-lanmode'),
|
|
@@ -72,5 +87,6 @@ export function summarize(results) {
|
|
|
72
87
|
if (bad.length === 0) return 'точки крепления на месте: ' + results.length + ' из ' + results.length
|
|
73
88
|
return 'ТОЧКИ КРЕПЛЕНИЯ УЕХАЛИ (' + bad.length + ' из ' + results.length + '): '
|
|
74
89
|
+ bad.map((item) => item.name).join('; ')
|
|
75
|
-
+ '. Плагин может работать не так, как
|
|
90
|
+
+ '. Плагин может работать не так, как задумано. Что именно не сошлось — в списке выше; '
|
|
91
|
+
+ 'ядро при этом может быть совершенно исправным'
|
|
76
92
|
}
|
package/lib/bridge.js
CHANGED
|
@@ -20,6 +20,7 @@ import https from 'node:https'
|
|
|
20
20
|
import { forceLoopback, isConnectionBundle } from './loopback-source.js'
|
|
21
21
|
|
|
22
22
|
import { allowed } from './access.js'
|
|
23
|
+
import { handoffLocation, shouldHandoff, tokenFrom } from './handoff.js'
|
|
23
24
|
import { REFUSED, isPrivileged } from './privileged.js'
|
|
24
25
|
|
|
25
26
|
function rewritten(headers, authority) {
|
|
@@ -58,6 +59,25 @@ function throttle(log, everyMs) {
|
|
|
58
59
|
* снаружи выглядит как поломка харнесса. Обратный прокси перед харнессом
|
|
59
60
|
* настраивают ровно так же, иначе он не годится.
|
|
60
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* Пояснение про ядро, которое обходится без нашей правки страницы.
|
|
64
|
+
*
|
|
65
|
+
* До 0.1.2 признак «страница открыта с этой же машины» вычислялся в браузере,
|
|
66
|
+
* и разделы настроек по сети приезжали пустыми — поэтому плагин правил это
|
|
67
|
+
* вычисление в сборке на лету. С 0.1.2 ядро решает то же самое у себя, по
|
|
68
|
+
* заголовку `Host` запроса. Мост подменяет `Host` на петлю с самого начала,
|
|
69
|
+
* значит вопрос закрыт раньше, чем страница успевает его задать.
|
|
70
|
+
*
|
|
71
|
+
* Говорим об этом один раз: иначе строка повторяется на каждый запрос сборки.
|
|
72
|
+
*/
|
|
73
|
+
let coreNoted = false
|
|
74
|
+
|
|
75
|
+
function noteSelfSufficientCore(log) {
|
|
76
|
+
if (coreNoted) return
|
|
77
|
+
coreNoted = true
|
|
78
|
+
log('ядро само решает, доверять ли адресу, — по заголовку запроса; правка страницы не нужна')
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
function relaxTimeouts(server, streamTimeoutMs) {
|
|
62
82
|
server.timeout = streamTimeoutMs
|
|
63
83
|
server.requestTimeout = streamTimeoutMs
|
|
@@ -84,6 +104,18 @@ export function startDirectBridge(ctx, options) {
|
|
|
84
104
|
const refuse = throttle(options.log, 10000)
|
|
85
105
|
const streamTimeoutMs = options.streamTimeoutMs ?? 0
|
|
86
106
|
const unlocked = options.unlockPrivileged !== false
|
|
107
|
+
// Токен спрашиваем у ядра при каждом отказе, а не запоминаем: он меняется
|
|
108
|
+
// при перезагрузке дерева плагинов, и запомненный увёл бы гостя по кругу.
|
|
109
|
+
const currentToken = () => {
|
|
110
|
+
if (options.autoAuth === false) return ''
|
|
111
|
+
try {
|
|
112
|
+
const connection = ctx.get?.('connection')
|
|
113
|
+
if (connection === undefined) return ''
|
|
114
|
+
return tokenFrom(connection.authenticatedUrl('http://' + authority))
|
|
115
|
+
} catch (noService) {
|
|
116
|
+
return ''
|
|
117
|
+
}
|
|
118
|
+
}
|
|
87
119
|
const hosts = options.hosts && options.hosts.length ? options.hosts : ['0.0.0.0']
|
|
88
120
|
|
|
89
121
|
/** Пускать ли этого гостя; отказ пишется в журнал не чаще раза в десять секунд. */
|
|
@@ -126,6 +158,21 @@ export function startDirectBridge(ctx, options) {
|
|
|
126
158
|
timeout: streamTimeoutMs || undefined,
|
|
127
159
|
}, (answer) => {
|
|
128
160
|
answer.setTimeout(streamTimeoutMs)
|
|
161
|
+
const handoff = shouldHandoff({
|
|
162
|
+
method: req.method, url: req.url, status: answer.statusCode, token: currentToken(),
|
|
163
|
+
})
|
|
164
|
+
if (handoff) {
|
|
165
|
+
// Тело отказа гостю не нужно, но выкачать его надо: брошенный ответ
|
|
166
|
+
// держал бы соединение с харнессом открытым.
|
|
167
|
+
answer.resume()
|
|
168
|
+
res.writeHead(303, {
|
|
169
|
+
'cache-control': 'no-store',
|
|
170
|
+
'location': handoffLocation(req.url, currentToken()),
|
|
171
|
+
'referrer-policy': 'no-referrer',
|
|
172
|
+
})
|
|
173
|
+
res.end()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
129
176
|
if (!patching) {
|
|
130
177
|
res.writeHead(answer.statusCode || 502, answer.headers)
|
|
131
178
|
answer.pipe(res)
|
|
@@ -135,7 +182,7 @@ export function startDirectBridge(ctx, options) {
|
|
|
135
182
|
answer.on('data', (chunk) => parts.push(chunk))
|
|
136
183
|
answer.on('end', () => {
|
|
137
184
|
const done = forceLoopback(Buffer.concat(parts).toString('utf8'))
|
|
138
|
-
if (!done.changed) options.log
|
|
185
|
+
if (!done.changed) noteSelfSufficientCore(options.log)
|
|
139
186
|
const body = Buffer.from(done.source, 'utf8')
|
|
140
187
|
const out = { ...answer.headers }
|
|
141
188
|
out['content-length'] = String(body.length)
|
package/lib/handoff.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Передача токена гостю из сети.
|
|
2
|
+
//
|
|
3
|
+
// Токен запуска ядро придумывает заново при каждом старте и держит в памяти
|
|
4
|
+
// процесса. Кука, которую он выдаёт в обмен на токен, привязана к адресу и
|
|
5
|
+
// живёт своим сроком, поэтому после перезапуска харнесса человек с сетевого
|
|
6
|
+
// адреса упирается в «authentication required» и идёт искать новую ссылку в
|
|
7
|
+
// журнале. Ссылку эту ядро печатает для себя, а не для него.
|
|
8
|
+
//
|
|
9
|
+
// Мост стоит ровно посередине и знает обе стороны: он видит отказ харнесса и
|
|
10
|
+
// может спросить у ядра нынешний токен. Поэтому вместо отказа он отправляет
|
|
11
|
+
// гостя по тому же адресу с токеном — дальше ядро само выдаёт куку и убирает
|
|
12
|
+
// токен из адреса.
|
|
13
|
+
//
|
|
14
|
+
// Это НЕ проверка личности: у кого есть доступ к порту, тот и войдёт. Плагин и
|
|
15
|
+
// так пишет об этом при запуске; здесь то же самое становится ещё заметнее.
|
|
16
|
+
|
|
17
|
+
/** Метка попытки: без неё браузер закружится между отказом и подстановкой. */
|
|
18
|
+
export const RETRY_MARK = 'dsh-lan-auth'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Достать токен из адреса, который выдаёт ядро (`authenticatedUrl`).
|
|
22
|
+
*
|
|
23
|
+
* Разбираем адрес, а не строку: в нём может не быть токена вовсе (сборка без
|
|
24
|
+
* проверки), и тогда подставлять нечего.
|
|
25
|
+
*
|
|
26
|
+
* @returns {string} пустая строка, если токена нет
|
|
27
|
+
*/
|
|
28
|
+
export function tokenFrom(url) {
|
|
29
|
+
if (typeof url !== 'string' || url === '') return ''
|
|
30
|
+
try {
|
|
31
|
+
return new URL(url).searchParams.get('token') ?? ''
|
|
32
|
+
} catch (notAnUrl) {
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Отправлять ли гостя за токеном.
|
|
39
|
+
*
|
|
40
|
+
* Только страница и только отказ: у запроса за данными свой ответ, и подмена
|
|
41
|
+
* его переходом сломала бы вызывающий код. Повторная попытка отсекается
|
|
42
|
+
* меткой: если и с токеном отказали, значит токен не тот, и ходить по кругу
|
|
43
|
+
* незачем.
|
|
44
|
+
*/
|
|
45
|
+
export function shouldHandoff({ method, url, status, token }) {
|
|
46
|
+
if (status !== 401) return false
|
|
47
|
+
if (method !== 'GET') return false
|
|
48
|
+
if (typeof token !== 'string' || token === '') return false
|
|
49
|
+
const path = pathOf(url)
|
|
50
|
+
if (path.pathname !== '/') return false
|
|
51
|
+
return !path.searchParams.has(RETRY_MARK)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Куда отправить: тот же путь, свой токен и метка попытки.
|
|
56
|
+
*
|
|
57
|
+
* Чужой токен из адреса выкидываем: он уже не подошёл, а два токена подряд
|
|
58
|
+
* ядро считает за подделку и отказывает вовсе.
|
|
59
|
+
*/
|
|
60
|
+
export function handoffLocation(url, token) {
|
|
61
|
+
const at = pathOf(url)
|
|
62
|
+
at.searchParams.delete('token')
|
|
63
|
+
at.searchParams.set('token', token)
|
|
64
|
+
at.searchParams.set(RETRY_MARK, '1')
|
|
65
|
+
return at.pathname + '?' + at.searchParams.toString()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pathOf(url) {
|
|
69
|
+
return new URL(typeof url === 'string' && url !== '' ? url : '/', 'http://dsh.invalid')
|
|
70
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -260,6 +260,10 @@ async function raiseListener(ctx, config, state) {
|
|
|
260
260
|
if (rules.length === 0) {
|
|
261
261
|
say('список разрешённых адресов пуст. Заполните allow — это не пароль, но круг сузит.')
|
|
262
262
|
}
|
|
263
|
+
if (config.autoAuth !== false) {
|
|
264
|
+
say('токен подставляется гостю сам: после перезапуска харнесса заходить заново не нужно, '
|
|
265
|
+
+ 'но и спросить его у гостя больше некому. Выключается настройкой autoAuth.')
|
|
266
|
+
}
|
|
263
267
|
}
|
|
264
268
|
|
|
265
269
|
state.listener = { scheme: tls ? 'https' : 'http', hosts, port }
|
|
@@ -273,6 +277,7 @@ async function raiseListener(ctx, config, state) {
|
|
|
273
277
|
unlockPrivileged: unlocked,
|
|
274
278
|
privilegedExtra: config.privilegedExtra,
|
|
275
279
|
streamTimeoutMs: config.streamTimeoutMs || 0,
|
|
280
|
+
autoAuth: config.autoAuth !== false,
|
|
276
281
|
})
|
|
277
282
|
}
|
|
278
283
|
|
|
@@ -326,12 +331,16 @@ function start(ctx, config) {
|
|
|
326
331
|
// поломка обязана быть заметной.
|
|
327
332
|
ctx.effect(() => {
|
|
328
333
|
let alive = true
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
// Держим сам сервис, а не путь к нему: отложенное обращение через ctx
|
|
335
|
+
// в новых сборках возвращает пустоту, хотя сервис на месте.
|
|
336
|
+
const webServer = ctx.webServer
|
|
337
|
+
const port = webServer && webServer.port
|
|
338
|
+
const fetchIndex = () => fetch('http://127.0.0.1:' + port + '/')
|
|
339
|
+
.then((answer) => answer.text().then((html) => ({ status: answer.status, html })))
|
|
331
340
|
|
|
332
341
|
// Даём харнессу договорить о себе: страница отдаётся не в первый миг.
|
|
333
342
|
const timer = setTimeout(() => {
|
|
334
|
-
checkAssumptions({ webServer
|
|
343
|
+
checkAssumptions({ webServer, fetchIndex })
|
|
335
344
|
.then((results) => {
|
|
336
345
|
if (!alive) return
|
|
337
346
|
state.assumptions = results
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-lanmode",
|
|
3
|
-
"version": "0.6.
|
|
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.",
|
|
3
|
+
"version": "0.6.5",
|
|
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. Adapted for DeepSeek Harness 0.1.2-alpha.1.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|