feat: dsh-lan-access — 局域网访问 dsh Web UI

用独立反向代理把 dsh Web UI 发布到局域网。官方 CLI 主动拒绝
`dsh web --host 0.0.0.0`(会把宿主机远程代码执行暴露到网络),
因此本插件让官方服务器保持只绑回环,另开监听并转发。

宿主端(index.js)
- 在 0.0.0.0:3082 监听,把 HTTP 与 WebSocket 转发到 127.0.0.1:<webServer 端口>
- 上游 Host/Origin 重写为回环,使官方 /api 信任围栏「通过」而不是被绕过
- 不改动 Set-Cookie:浏览器按请求 URL 建立 host-only cookie,转发层无需干预
- 监听端 DNS 重绑定防护;targetHost 仅接受回环地址(不会变成开放代理)
- /api/dsh-lan-access/summary 复用官方 connection.admit() 做围栏与浏览器认证
- 启动横幅打印带 launch token 的局域网链接
- 端口被占用时只告警不抛出,并在面板上报 listening:false,不展示不可用的二维码
- ownsHostCompat(默认关):仅为非回环页面声明 ownsHost,恢复官方设置界面

客户端(src/ 经 scripts/build-client.mjs 生成 client.js)
- Settings → 局域网访问:局域网地址列表、复制/打开、选中地址的二维码
- 零依赖二维码编码器(byte 模式 / ECC M / version 1–10)

组合层
- 只 insert 一行,不覆盖任何 shipped row:停用本插件只会移除局域网监听,
  回环 Web UI 不受影响

验证
- 宿主转发契约 28/28;客户端契约 25/25
- 二维码编码器与 npm qrcode 参考实现在全版本 × 全 8 掩码下逐模块比对 240/240 一致
- 端到端:局域网 token 首访 303 并铸 cookie → 应用 200;无凭证 401;
  WebSocket 升级 101,且与直连回环逐项行为一致
This commit is contained in:
2026-09-27 03:49:12 +08:00
commit ced05e06a3
17 changed files with 3174 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
# @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).
## 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
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