304379c80c
test-qr.mjs 需要一个 npm qrcode 作为对照(仅测试用,非运行时依赖), 但脚本与 README 都没说明如何准备,克隆后直接 npm test 会在这一步失败。 现在脚本直接打印可复制的准备命令,README 中英双语也补充了说明与 QR_ORACLE_DIR 的用法。
145 lines
8.8 KiB
Markdown
145 lines
8.8 KiB
Markdown
# @sutong/dsh-lan-access
|
||
|
||
Publish the dsh Web UI on your local network with a **standalone reverse proxy**: it listens on `0.0.0.0:<port>`, forwards HTTP and WebSocket to the loopback web server, and **keeps the official launch-token login**.
|
||
|
||
[简体中文](README.md) | **English**
|
||
|
||
## Why a plugin is needed
|
||
|
||
`dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose (`@deepseek-ai/dsh-web-app/lib/startup.js`: it would expose this machine's remote code execution to the network). The official web server therefore always binds loopback, so LAN access has to be a second listener that forwards to it.
|
||
|
||
## How it differs from dsh-lan-proxy
|
||
|
||
| | This plugin | dsh-lan-proxy |
|
||
|---|---|---|
|
||
| Default login | **the launch token is required** (the link from the banner or the QR code) | `injectToken: true` — anyone who can reach the port gets in untokened |
|
||
| Upstream `Host` | rewritten to loopback, so the official fence passes | rewritten to loopback |
|
||
| `Set-Cookie` | left untouched (a browser binds a host-only cookie to the **request URL**, so the forwarder needs no rewrite) | paired with token injection and a 401 replay |
|
||
| WebSocket | handshake forwarded byte-for-byte; `permessage-deflate` negotiates end to end and **the proxy decompresses nothing** | compression bridge (adds a decompression surface) |
|
||
| Shipped rows | **overrides none**, so a failure or a manual disable cannot affect the loopback UI | inserts its own row plus a compatibility injection |
|
||
|
||
> **Port collision**: dsh-lan-proxy claims `3081` by default, so this plugin defaults to `3082`. They can coexist, but running both exposes the UI twice — keep one.
|
||
|
||
## Install
|
||
|
||
```sh
|
||
dsh plugin --profile web add /path/to/dsh-lan-access
|
||
```
|
||
|
||
Then restart `dsh web` once. The terminal prints the tokenized LAN links.
|
||
|
||
## Configuration
|
||
|
||
Set the row's `config` (Host Profile → Plugins → this plugin → Configure, or edit `cordis.patch.yml`).
|
||
|
||
| Key | Default | Meaning |
|
||
|---|---|---|
|
||
| `enabled` | `true` | Master switch; off means no listener |
|
||
| `host` | `0.0.0.0` | Listen address |
|
||
| `port` | `3082` | Listen port (avoids dsh-lan-proxy's 3081) |
|
||
| `targetHost` | `127.0.0.1` | Upstream host. **Loopback only** — anything else is refused at startup, so this can never become an open proxy |
|
||
| `targetPort` | `0` | Upstream port; `0` follows the live `webServer` port |
|
||
| `printBanner` | `true` | Print the LAN links at startup |
|
||
|
||
## Usage
|
||
|
||
1. **Startup banner**:
|
||
|
||
```
|
||
dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener)
|
||
dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX
|
||
```
|
||
|
||
2. **Web UI panel**: Settings → **LAN Access** (the plugin owns this settings page). It lists every LAN address with copy/open actions and renders a **QR code** for the address you select, so a phone can scan straight in.
|
||
|
||
The `token` is used only on the first visit: the server mints a 30-day cookie and 302s to the clean `./`, so **the token never stays in the address bar or history**. That device then enters by cookie.
|
||
|
||
## Security model
|
||
|
||
- **Authentication is not relaxed.** No token injection, no fence bypass: a LAN device must hold the link from the banner or the QR code. That is stricter than trusting the whole network.
|
||
- **The official fence stays in force.** `/api` accepts only loopback or declared authorities, and `Origin` must match `Host`; the proxy rewrites the upstream `Host`/`Origin` to loopback so those checks *pass* rather than being bypassed.
|
||
- **DNS-rebinding guard**: the listener accepts only `localhost`, loopback, the LAN IP literals it publishes, or its own listen address. Hostnames are dropped.
|
||
- **Upstream locked to loopback**, so it cannot be turned into an open forward proxy.
|
||
- **No extra decompression surface**: no response or frame compression in the middle.
|
||
- **The panel's data route** (`/api/dsh-lan-access/summary`) is mounted on the official web server and **reuses the official `connection.admit()`** — the same fence and browser authentication — so only an authenticated page can read the token.
|
||
- **⚠️ Blast radius**: a device holding that link has **full dsh control, including command execution on this host**. Enable it only on a trusted network, and disable or uninstall it when you are done. Every link in the list is a key.
|
||
|
||
## Settings screens on a LAN page (`ownsHostCompat`)
|
||
|
||
DSH restricts the durable settings surface to loopback pages: the client derives `isLoopback`
|
||
from `location.hostname` (`dsh-client-connection/lib/client.js`), so a non-loopback page gets
|
||
`persistence = "memory"` (`dsh-client-ui-settings/lib/client.js`), the settings mirror never
|
||
answers with a `view`, and the Models/provider page reports
|
||
**settings are unavailable in this browser** — while the General page's "open config file"
|
||
action disappears.
|
||
|
||
**This is unrelated to forwarding correctness**: every LAN exposure method hits it, and
|
||
`127.0.0.1:3080` is unaffected. There is no matching server-side gate (the `/api` fence plus the
|
||
launch token and cookie are the whole authorization surface), so this plugin exposes a switch:
|
||
|
||
| Key | Default | Meaning |
|
||
|---|---|---|
|
||
| `ownsHostCompat` | `false` | Declare `ownsHost` for **non-loopback pages**, restoring the official settings screens (a loopback page is untouched) |
|
||
|
||
This profile sets it to `true`. The tradeoff: that page can then read and write server settings,
|
||
credentials included. Since a device holding the tokenized link already has full control
|
||
(command execution on this host), this is not a new class of capability — but it does step past
|
||
the shipped loopback isolation. Set it to `false` to restore that posture and change settings
|
||
locally through `127.0.0.1:3080` (or an `ssh -L 3080:127.0.0.1:3080 <host>` tunnel).
|
||
|
||
## The directory picker behind "Add workspace"
|
||
|
||
`directory-picker-auto` picks ONE backend at boot from **host-side** facts: `native` requires "a
|
||
loopback-only bind (an all-interfaces bind admits remote browsers no OS chooser can reach)" plus a
|
||
servable display. This bundle deliberately keeps the official webserver on loopback (the LAN is a
|
||
separate listener on 3082), so a LAN deployment still resolves to `native` — and the dialog opens on
|
||
the **server machine**, where the remote operator can neither see nor reach it.
|
||
|
||
This bundle's patch therefore pins the `browse` pair, the documented way to force an interaction
|
||
("pinning an interaction remains composing that pair directly instead of this row"):
|
||
|
||
- `@deepseek-ai/dsh-host-directory-picker-browse` (backend)
|
||
- `@deepseek-ai/dsh-client-ui-directory-picker-browse` (surface)
|
||
|
||
That backend lists the server's directory tree inside the page; upstream describes it as serving
|
||
"remote clients the dialog backend cannot".
|
||
|
||
**Tradeoff**: the seam mounts exactly one backend per process, so a **local page switches to the
|
||
in-page browser too** — there is no "native locally, in-page remotely" combination. To restore the
|
||
adaptive choice, remove the last three entries of `cordis.patch.yml` (the
|
||
`- id: directory-picker / disabled: true` entry and the `insert` block after it).
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Cause and fix |
|
||
|---|---|
|
||
| `LAN listener unavailable ... EADDRINUSE` | The port is taken (commonly dsh-lan-proxy on 3081). Change `port`, or disable the other plugin. The panel shows the same error instead of a dead QR code. |
|
||
| A LAN device gets 401 | It opened the bare address without a token and has no cookie yet. Use the `?token=` link from the banner or the panel. |
|
||
| A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. |
|
||
| The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. |
|
||
| Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. |
|
||
|
||
## Development
|
||
|
||
```sh
|
||
node scripts/build-client.mjs # generates client.js from src/panel.js + src/qrcode.js
|
||
node scripts/smoke.mjs # host forwarding contract (HTTP/WS/Host/Origin/cookie/bind failure)
|
||
node scripts/test-qr.mjs # module-by-module comparison against the npm qrcode reference
|
||
# test-qr.mjs compares against a reference implementation (test-only, never a runtime dep):
|
||
# mkdir -p /tmp/qr-oracle && cd /tmp/qr-oracle && npm init -y && npm i qrcode@1.5.4
|
||
# elsewhere: QR_ORACLE_DIR=<dir> node scripts/test-qr.mjs
|
||
node scripts/test-client.mjs # browser contract (slot registration, render, route agreement)
|
||
```
|
||
|
||
`client.js` is generated: edit `src/` and rebuild.
|
||
|
||
The QR encoder (`src/qrcode.js`) is a dependency-free implementation (byte mode, ECC M, versions 1–10) verified module-by-module against the npm `qrcode` reference across every version and every mask (240/240 identical).
|
||
|
||
## Rollback
|
||
|
||
Disable or uninstall the plugin from the Plugins page. Because it **overrides no shipped row**, disabling it only removes the LAN listener; loopback `dsh web` is unaffected.
|
||
|
||
## License
|
||
|
||
MIT
|