Files
dsh-lan-access/README.en.md
T
sutong ced05e06a3 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,且与直连回环逐项行为一致
2026-09-27 03:49:12 +08:00

120 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @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