用独立反向代理把 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,且与直连回环逐项行为一致
@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.
简体中文 | 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
3081by default, so this plugin defaults to3082. They can coexist, but running both exposes the UI twice — keep one.
Install
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
-
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 -
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.
/apiaccepts only loopback or declared authorities, andOriginmust matchHost; the proxy rewrites the upstreamHost/Originto 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 officialconnection.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
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