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

7.3 KiB
Raw Blame History

@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 3081 by default, so this plugin defaults to 3082. 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

  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

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