明文 HTTP 的局域网页面不是安全上下文,navigator.clipboard 不存在,
旧代码落到 else 分支直接报告成功却什么都没复制;而且 .then(done, done)
连 Promise 被拒(权限拒绝)也当成功——两种情况都会谎报「已复制」。
现在:
- 只在真的写入剪贴板后才报成功;clipboard API 被拒也继续回退
- 回退到 document.execCommand('copy')(明文 HTTP 下仍然可用)
- 两者都不可用时选中链接并提示手动 Ctrl/⌘+C,不再谎报
- URL 改为可选中的只读输入框(原先是没有省略号就无法看全的 div)
新增 7 项回归测试覆盖:clipboard 缺失时确实走 execCommand、可用时走
API 且不重复复制、被拒时回退、两者都无时不抛异常。旧代码在该测试下失败。
README 中英双语排障表补充该现象与 HTTPS 说明。
@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).
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. |
| "Copy" does nothing / nothing reaches the clipboard | A plain-HTTP LAN page is not a secure context, so navigator.clipboard does not exist there. The plugin falls back to document.execCommand('copy'); when a browser blocks both it selects the link and asks for Ctrl/⌘+C instead of falsely reporting success. The real clipboard API needs HTTPS. |
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
# 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