idoall/dsh-lan-guard

dsh-lan-guard

把官方 DSH Web UI 安全地开放到局域网:不改动 DSH 自身的回环绑定,另开一个带密码门禁、自签 HTTPS 与可逐个吊销的设备配对的反向代理端口,并提供手机扫码入口。

待分类

DSH LAN Guard

Expose the desktop DSH Web UI to your LAN safely: a gated reverse proxy with self-signed HTTPS by default and a QR code to open it on a phone. DSH's own loopback binding and the official UI stay untouched.

English | 中文

What it does · Quick start · Settings · Compatibility · Security boundary · Troubleshooting · Changelog

DSH LAN Guard is a DeepSeek Harness community plugin. It registers one section in the official settings page (settings.section), replaces no official layout, and modifies neither DSH source nor DSH's own listen binding.

DSH serves its Web UI on 127.0.0.1 only, and it deliberately refuses to bind 0.0.0.0. This plugin leaves that binding alone and opens a second, gated listener on another port, proxying the official UI outward as-is: a password gate, self-signed HTTPS by default, a QR code to open it on a phone, and device pairing you can approve or block one by one. One restart after install is all it takes — no hand-written configuration.

Settings → 局域网访问 → 扫码访问: running state, passwordless link and a scannable QR code

What it does

  • Gated reverse proxy — relays HTTP and WebSocket end to end (including the official UI's /api/remote.mux long connection), rewrites Host/Origin, strips hop-by-hop headers, and answers 502 when the upstream is down. DSH's own binding and configuration are never touched.
  • LAN-ready by default, but reachable is not the same as enterable — it binds 0.0.0.0, so one restart after install is enough. The gate is on by default and refuses every device until you set an access password, and TLS is self-signed by default — nothing travels in clear. Switch to "this machine only" in the settings page if you prefer.
  • Two passwords — PBKDF2-SHA256 (600,000 iterations). The access password logs visitor devices in; the admin password unlocks this settings page's management console (it falls back to the access password). Plus a dsh_ passwordless link, persistent visitor sessions, per-IP lockout and CSRF checks.
  • Self-signed HTTPS by default, with a stable CA — generates a DSH LAN Guard CA and signs a leaf certificate for the current NIC addresses. Changing IP only re-signs the leaf, so each device trusts the CA once.
  • Your own machine is never locked — direct 127.0.0.1 access is physically unlocked (whoever can use this computer could change these settings anyway). Remote access follows adminPolicy: read-only (default), password-unlocked, or open.
  • Device pairing and permanent blocking — a phone names itself once when it first passes the gate, receives an HttpOnly device-identity cookie, and appears under 已授权设备 (name / created / last used / source IP) where you can revoke and block it. Blocking does not rely on device fingerprinting: re-pairing with the access password from another browser is refused too, and unblocking is the only way back.
  • Settings inside the official page — the "局域网访问" section has four tabs: QR access, authentication, authorised devices, and connection & certificates. All typography and colours use the official design tokens; no official layout is replaced.
  • Adding a workspace from a phone (smart routing) — DSH resolves its directory picker once at boot, and a loopback bind with a display resolves to native: tapping "添加工作区" on a phone actually opens the folder dialog on the host's screen. This plugin shadows the official pick flow from the browser half at a lower slot priority, so the local browser keeps the OS dialog while a remote device gets an in-page directory browser (breadcrumbs, shortcuts, directory list). The chosen path still goes through DSH's own workspace flow — this plugin only reads directories, never writes anything.
  • Configurable port and listen scope — defaults to 3081 (DSH's port + 1) and walks up to ten ports when that one is taken, with an availability check in the settings page. The listen scope toggles between "LAN (default)" and "this machine only"; both need a DSH restart.
  • Optional mDNS — off by default; advertises _dsh-lan-guard._tcp when enabled.
  • Update check — the settings page shows "current version → latest on npm" with a copyable upgrade command. The plugin never installs or restarts anything itself.

Quick start

Requirements:

  • DeepSeek Harness with a Web profile
  • Node.js 20 or newer
  • Verified DeepSeek Harness: 0.2.1-rc.3 (API review, type checking and automated regression; phone/browser acceptance on rc.3 is pending).

Install from npm:

dsh plugin --profile web add dsh-lan-guard@latest

Install from GitHub:

dsh plugin --profile web add "github:idoall/dsh-lan-guard"

Install from a local clone:

git clone https://github.com/idoall/dsh-lan-guard.git
cd dsh-lan-guard
pnpm install && pnpm run build
dsh plugin --profile web add "link:$(pwd)"

Then restart DSH once and open Settings → 局域网访问. After that restart the plugin is already listening on the LAN (default 0.0.0.0:3081, self-signed HTTPS + gate); all you do is:

  1. Set an access password (at least 8 characters) under 安全认证. Until you do, the gate refuses every device.
  2. Check the listen scope under 连接与证书 (LAN by default) and pick the NIC to publish on — the NIC decides which IP the QR code / access URL uses and which addresses the self-signed certificate covers.
  3. Scan the QR under 扫码访问, trust DSH LAN Guard CA once on the phone, enter the access password, and name the device. The phone then runs the official DSH UI.

Remote devices are read-only by default (adminPolicy: local_only): they can use DSH but cannot change plugin settings. Switch the policy on the desktop if you want a phone to manage them.

Settings

Everything lives under Settings → 局域网访问, in four tabs. The non-sensitive switches (enabled, listenPort, listenHost, networkInterface, settingsUnlock, answerHeartbeat, socketWatchdog, mobileCompat, mobileScrollFix, mobileTurnRail, auth.mode, auth.adminPolicy, auth.adminProtection, auth.allowLoopback, auth.requirePairing, auth.requireApproval) are editable directly; listenPort and listenHost take effect on the next DSH restart; settingsUnlock, socketWatchdog, mobileCompat, mobileScrollFix and mobileTurnRail take effect on the next page load while answerHeartbeat also applies to connections that are already open; dataDir and tls.* are startup fields that need a profile-patch edit.

SettingDefaultEffect
Listen scopeLAN 0.0.0.0Whether the LAN can reach the port. "This machine only 127.0.0.1" is more conservative; needs a DSH restart. Both choices keep the gate and self-signed HTTPS in force.
Proxy port3081DSH's port + 1; walks up to ten ports when taken, with an availability check. Needs a DSH restart.
NIC to publish onautomaticDecides which IP the QR code / access URL uses and which addresses the certificate covers; virtual NICs are de-prioritised and labelled.
Auth modepasswordless QR + passwordAlso "password only" or "secure token only". Switching revokes every existing visitor session.
Access passwordunsetThe login password for visitor devices. While unset, the gate refuses every device.
Admin passwordunsetUnlocks this settings page's management console; falls back to the access password.
Loopback exemptonDirect 127.0.0.1 access skips the gate (physically unlocked).
Remote management rightsthis machine onlyWhether LAN devices may manage: "this machine only" is read-only; "password unlock" needs an unlock first; "not locked" adds no requirement. Browsing directories and adding a workspace remotely needs one of the latter two.
Management needs an unlockonTurned off, a remote session that satisfies the policy may change settings directly — except under "this machine only", which never allows remote management.
Official settings page on the LANonDSH opens its official settings surface only to loopback pages, so a LAN device opening Settings → Models reports settings are unavailable in this browser. With this on, any device that passes the gate (phones included) can use those pages after a page refresh. It is a UI unlock, not a new privilege: the settings RPC is gated by the visitor gate either way, and DSH still redacts secret reads. Refresh the page to apply; no restart needed.
Answer the host heartbeatonDSH pings every WebSocket every 2 s and drops it after two unanswered pings (measured: 6 s). A suspended phone cannot answer, and the session transcript — the only part of the UI that rides that socket — then loads incompletely or disconnects. With this on the proxy answers instead; the phone's own Pong, when it arrives, is a harmless duplicate. Applies immediately, including to open connections.
Socket watchdogonPage patch: a WebSocket still in CONNECTING after 8 s is closed, and a page resumed with nothing open reloads itself once (at most three in a row per tab, backing off from 20 s). Only a page that genuinely cannot connect is affected. Applies on page refresh.
Mobile compatibility shimsonPage patch: fills in AbortSignal.any/AbortSignal.timeout/Promise.withResolvers/Iterator and the mobile metas. Without those APIs DSH's client throws inside its session-stream path and the UI just sits on 「载入历史…」 with no error at all. On a modern engine every branch is a no-op. Applies on page refresh.
Narrow-screen scroll fixonDSH's own shell clips the conversation column on a phone (pI_x6G_frame, measured 844/1688) and the page cannot scroll, so content is visible but cannot be dragged. The patch only acts when narrow screen + mobile UA + page cannot scroll + a clipping overflowing layer exists; healthy pages are untouched. ?lgdiag=1 prints a layout report. Applies on page refresh.
Require namingonA new device must name itself once before it appears in the device list.
Require approvaloffWhen on, a named device still needs your 批准 before it is let in.
TLSself-signed HTTPSTurning it off sends the gate password in clear; a non-loopback bind with TLS off is refused at startup unless you set tls.allowInsecureLan: true.

Connection & certificates: the listen-scope switch between LAN (default) and this-machine-only, the proxy port with its availability check, and NIC selection

The plugin reads its config from its Cordis entry. Every key has a usable default, so a fresh install works as-is:

# ~/.dsh/profiles/web/cordis.patch.yml (optional: write only what you want to change)
- id: dsh-lan-guard
  config:
    listenHost: 0.0.0.0              # LAN-facing by default; '127.0.0.1' = this machine only, or one NIC IP
    listenPort: 3081                 # DSH port + 1; auto-walks up to 10 ports when taken
    networkInterface: en0            # optional: publish on one NIC (empty = automatic)
    settingsUnlock: true             # LAN devices may use the official settings page (default on)
    answerHeartbeat: true            # proxy answers the WebSocket heartbeat (default on)
    socketWatchdog: true             # page-side socket watchdog (default on)
    mobileCompat: true               # mobile compatibility shims (default on)
    mobileTurnRail: false            # show DSH's own turn rail on narrow screens (default off)
    dataDir: ~/.dsh/profiles/web/data/dsh-lan-guard   # optional; this is the derived default
    tls:
      mode: self-signed              # 'self-signed' (default) | 'provided' | 'off'
      allowInsecureLan: false        # required acknowledgement for LAN plain HTTP
    mdns:
      enabled: false                 # advertise _dsh-lan-guard._tcp
    auth:
      mode: token_and_password       # 'token_and_password' | 'password' | 'token'
      adminPolicy: local_only        # 'local_only' (default) | 'password_unlock' | 'open'
      adminProtection: true          # admin console needs the admin password
      allowLoopback: true            # 127.0.0.1 visitors skip the gate (physically unlocked)
      requirePairing: true           # new remote devices must name themselves once

dataDir is the only key that needs explaining: omit it and the plugin uses <active profile>/data/dsh-lan-guard (e.g. ~/.dsh/profiles/web/data/dsh-lan-guard); set it and your value wins (a leading ~ is expanded). It only decides where the plugin's private state (password hashes, device-token hashes, sessions, self-signed CA) lives — never whether the plugin works.

Authentication: the three-way mode selector, access and admin password fields, and the loopback-exempt switch

Authorised devices: naming and admin-approval switches, plus the device list with revoke-and-block

Compatibility

Current version: plugin 0.7.6, compatible with DeepSeek Harness 0.2.1-rc.3. Each row below is one plugin release: the DSH versions it was verified against, and what that release did.

PluginVerified DeepSeek HarnessWhat this version is
0.7.6 (latest)0.2.1-rc.3, 0.2.1-rc.2, 0.2.1-alpha.5, 0.2.1-alpha.4, 0.2.1-alpha.3, 0.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2, 0.1.7-rc.1Compatibility release for DSH 0.2.1-rc.3; no runtime code change. API/type/automated checks only; phone/browser acceptance pending.
0.7.5 (published)0.2.1-rc.2, 0.2.1-alpha.5, 0.2.1-alpha.4, 0.2.1-alpha.3, 0.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Keeps up with DSH 0.2.1-rc.2: compatibility statement only, no code change.
0.7.40.2.1-alpha.5, 0.2.1-alpha.4, 0.2.1-alpha.3, 0.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Keeps up with DSH 0.2.1-alpha.5: compatibility statement only, no code change.
0.7.30.2.1-alpha.4, 0.2.1-alpha.3, 0.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Keeps up with DSH 0.2.1-alpha.4: compatibility statement only, no code change.
0.7.20.2.1-alpha.3, 0.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Keeps up with DSH 0.2.1-alpha.3: compatibility statement only, no code change.
0.7.10.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Documentation release: the README and past release notes were rewritten in plain language. Behaviour is identical to 0.7.0.
0.7.00.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2The phone turn rail finally works: no more drifting card, and previews follow your finger while you hold and slide (off by default). Blank pages after a restart are fixed too.
0.6.00.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Fixed "you can see the rail but not its content" on a phone: holding a tick for ~0.3s now opens a card with that turn's prompt and reply.
0.5.00.2.1-alpha.1, 0.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Keeps up with the new DSH release (declaration only) and adds the phone turn rail, off by default.
0.4.60.2.0-rc.2, 0.2.0-rc.1, 0.1.7-rc.2Keeps up with DSH 0.2.0-rc.2: declaration only, plus confirmation that the old mobile scroll compensation is no longer needed.
0.4.50.2.0-rc.1, 0.1.7-rc.2Fixes long sessions that still would not scroll when the transcript hydrated a beat late and the fix stopped early.
0.4.40.2.0-rc.1, 0.1.7-rc.2Fixes iOS sessions opening away from the newest message and barely dragging.
0.4.30.2.0-rc.1, 0.1.7-rc.2, 0.1.7-rc.1A different approach: repair the mobile height chain instead of patching the content layer, and drop the self-made back-to-bottom button.
0.4.20.1.7-rc.2Brings back DSH's own back-to-bottom button and fixes the grey sidebar it fought with.
0.4.10.1.7-rc.2The first mobile scroll fix: it only acts on pages that really cannot scroll, and adds the ?lgdiag=1 report.
0.4.00.1.7-rc.2, 0.1.7-rc.1The phone connection stops dropping (the proxy answers DSH's heartbeats), plus a disconnect watchdog and a connection check-up.
0.3.60.1.7-rc.2, 0.1.7-rc.1Add a workspace over LAN, and fix remote device management and a few UI problems.
0.3.50.1.7-rc.2, 0.1.7-rc.1http on the TLS port now redirects to https and logs it, and deleting a device no longer locks the browser out.
0.3.40.1.7-rc.2, 0.1.7-rc.1LAN devices can use DSH's own settings pages (on by default; it unlocks the UI and grants no new permissions).
0.3.30.1.7-rc.2, 0.1.7-rc.1Both first-visit steps are explained up front, expired links can sign in again, and the block-list wording matches the code.
0.3.20.1.7-rc.2, 0.1.7-rc.1Install and go: LAN-facing default + derived dataDir; Liquid Glass settings page at official sizes; single-line scrolling access URL
0.3.10.1.7-rc.2, 0.1.7-rc.1Verification release for 0.1.7-rc.2: no code change, only compatibility metadata
0.3.00.1.7-rc.1Device approval and permanent blocking; fixed the blank page when opening a shared ?auth= link
0.2.00.1.7-rc.1Update check; removed the corner status pill; spacing fixes
0.1.10.1.7-rc.1Documentation release: bilingual user READMEs
0.1.00.1.7-rc.1First release: gated reverse proxy, self-signed HTTPS, device pairing, settings page, QR access
  • Declared range >=0.1.7-rc.1 <0.3.0 (dsh.engines.dsh); verified releases: 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2, 0.2.1-alpha.1, 0.2.1-alpha.3, 0.2.1-alpha.4, 0.2.1-alpha.5, 0.2.1-rc.2, 0.2.1-rc.3 (10 releases; the rc.3 verification scope is described above). DSH versions not listed are unverified — verify them yourself before use. The upper bound was widened from <0.2.0 in 0.4.3 because profile load refuses a bundle whose range excludes the running release, and a plain <0.2.0 would exclude the 0.2.0 stable.
  • Host/client interfaces this plugin uses: webServer.register / webServer.tapIndex (indexTaps), connection.requestRejection, connection.authenticatedUrl, the additive settings.section seat, @deepseek-ai/schemastery, and profileContext (for deriving the default data directory).
  • Breaking default change (from 0.3.2): listenHost now defaults to 0.0.0.0 instead of 127.0.0.1, so one restart after install is enough; 0.3.1 and earlier default to loopback only. The gate and self-signed TLS defaults are unchanged (with no password the gate still refuses every device). See the CHANGELOG.
  • 0.4.0 verification status: all three changes carry measurements, not just specs.
    1. Proxy answers the heartbeat: a Node client against the real DSH measured a 6.0 s reap after it stopped sending Pongs (close 1006, no Close frame); with the answerer on the same link survived 20 s (10 pings answered). An end-to-end spec reproduces the pair against a DSH-shaped upstream (40 ms pings, destroy after two misses): reaps without the answerer, survives with it.
    2. Page patches: the injected scripts are executed in the suite against a fake environment and clock — "a socket stuck in CONNECTING is closed after 8 s", "never reloads while one socket is OPEN", "never reloads a page that created no socket", "reloads once after a resume with nothing open, then backs off within the cooldown and cap", "never reloads while hidden".
    3. Browser verification (BrowserSkill, Chrome 154, through a temporary loopback forward that still traverses the whole plugin path): /api/remote.mux handshake 3 ms; the session/follow snapshot arrives in 43–110 ms as a single 2.1 MB frame; 310 frames over 12 s with no interruption; and with the page's main thread frozen for 12 s a connection without the answerer received close 1006. The suite is green at 349 specs (330 in 0.3.6). The real-DSH install verification follows the release (this machine runs the installed 0.3.6, so the code takes effect after a reinstall plus a DSH restart).

Why a phone's session socket dies (measured 2026-09-28)

The session transcript — the 「载入历史…」 part of the UI — rides the WebSocket only; the session list, the goal chip and the statistics arrive over HTTP, which is why the failure looks like "everything is there except the conversation". Three facts define it:

FactSource
The host pings every 2 s and drops a socket after two unanswered pingswebsocketHeartbeatIntervalMs: 2000, MAX_MISSED_HEARTBEATS: 2
A client that stops sending Pongs is dropped after 6.0 s (close 1006, no Close frame)measured three ways: direct on 3081, through a forward, and with a frozen browser main thread
A phone that switches away or locks its screen cannot answeriOS suspends the page; WebKit additionally has a case where new WebSocket() stays in CONNECTING forever after a resume while HTTP keeps working

Three layers ship in this release, all on by default and each individually switchable:

  1. answerHeartbeat — while relaying, the proxy reads the upstream→browser control frames and answers a Ping with a masked Pong on the client's behalf. RFC 6455 allows an unsolicited Pong and the host only resets its counter on "a Pong arrived", so the phone's own duplicate is harmless. This is the only layer that stops the host from reaping a suspended phone.
  2. socketWatchdog — the page-side half: close a socket that is stuck in CONNECTING, and reload once when a resume leaves nothing open.
  3. mobileCompat — AbortSignal.any/timeout, Promise.withResolvers, Iterator and the mobile metas. DSH's client calls AbortSignal.any on its session-stream path, and Session.doOpen re-throws non-transport errors, so a missing API shows up as a permanent 「载入历史…」 with no error text.

Settings → 局域网访问 → 连接与证书 → 手机连接与自愈 carries a connection health check: it inspects which APIs this browser lacks, which patches the page received, runs a real WebSocket handshake against the current origin, and shows the proxy-side counters (open connections, upgrades, refusals, answered heartbeats) plus the last connection's duration, bytes and whether it died abnormally.

  • 0.3.6 verification status: the change spans the host side (a new directory-listing route and cookie relaying) and the client side (the directory browser, t