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.
- 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.1.7-rc.2
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:
- Set an access password (at least 8 characters) under 安全认证. Until you do, the gate refuses every device.
- 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.
- 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, 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; dataDir and tls.* are startup fields that need a profile-patch edit.
| Setting | Default | Effect |
|---|
| Listen scope | LAN 0.0.0.0 | Whether 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 port | 3081 | DSH's port + 1; walks up to ten ports when taken, with an availability check. Needs a DSH restart. |
| NIC to publish on | automatic | Decides which IP the QR code / access URL uses and which addresses the certificate covers; virtual NICs are de-prioritised and labelled. |
| Auth mode | passwordless QR + password | Also "password only" or "secure token only". Switching revokes every existing visitor session. |
| Access password | unset | The login password for visitor devices. While unset, the gate refuses every device. |
| Admin password | unset | Unlocks this settings page's management console; falls back to the access password. |
| Loopback exempt | on | Direct 127.0.0.1 access skips the gate (physically unlocked). |
| Require naming | on | A new device must name itself once before it appears in the device list. |
| Require approval | off | When on, a named device still needs your 批准 before it is let in. |
| TLS | self-signed HTTPS | Turning 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)
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.3.2, verified on DeepSeek Harness 0.1.7-rc.2.
| Plugin | Verified DeepSeek Harness | What this version is |
|---|
0.3.2 | 0.1.7-rc.2, 0.1.7-rc.1 | Install and go: LAN-facing default + derived dataDir; Liquid Glass settings page at official sizes; single-line scrolling access URL |
0.3.1 | 0.1.7-rc.2, 0.1.7-rc.1 | Verification release for 0.1.7-rc.2: no code change, only compatibility metadata |
0.3.0 | 0.1.7-rc.1 | Device approval and permanent blocking; fixed the blank page when opening a shared ?auth= link |
0.2.0 | 0.1.7-rc.1 | Update check; removed the corner status pill; spacing fixes |
0.1.1 | 0.1.7-rc.1 | Documentation release: bilingual user READMEs |
0.1.0 | 0.1.7-rc.1 | First release: gated reverse proxy, self-signed HTTPS, device pairing, settings page, QR access |
- Declared range
>=0.1.7-rc.1 <0.2.0 (dsh.engines.dsh); DSH versions not listed are unverified — verify them yourself before use.
- Host/client interfaces this plugin uses:
webServer.register / 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.
The official UI is reused with zero modifications and adapts on a phone viewport:
The official DSH UI at a 390px phone viewport: the plugin only proxies, the interface is the official one
Security boundary
- DSH's own listen address is never changed; the plugin modifies no DSH configuration, session data, or official UI.
- Gate before listener — the listener only opens after the gate object is constructed. The LAN-facing default is acceptable precisely because
auth.enabled defaults to true, the gate refuses every non-loopback device while no access password is set, and TLS defaults to self-signed. All three must hold together.
- Secrets (
secrets.json, devices.json, sessions) live in dataDir with mode 600; passwords are stored only as PBKDF2-SHA256 hashes, a device token is returned in plaintext once and only its SHA-256 hash is stored, and the passwordless-link token is never written to logs.
- The proxy stamps every forwarded request with an unforgeable source marker so the host can tell "the machine's own operator" from "a visitor through the proxy".
- Loopback access is physically unlocked by design — whoever can use this computer could change these settings anyway.
- The access password is shared: revoking a device invalidates that device's identity cookie immediately, but the same browser can re-pair with the password. Permanently blocking one machine would need device fingerprinting or per-device tokens, which this project deliberately avoids.
- LAN only: no public tunnels, no IM bots, no port forwarding.
- The plugin installs, restarts and pushes nothing: you copy and run the upgrade command yourself.
Troubleshooting
The phone says the certificate is not trusted. The CA is self-signed: install/trust DSH LAN Guard CA once per device. Compare the SHA-256 fingerprint shown under 连接与证书 first.
The phone cannot connect at all. Confirm both devices are on the same network and that the address matches the QR code, check for a VPN or a "private relay"-style feature intercepting traffic, and make sure the listen scope was not switched to "this machine only".
The settings page says the port is open to the LAN but no access password is set. That is the expected intermediate state: the port is reachable, but the gate refuses every device and leaks nothing. Set an access password under 安全认证.
"Configured port X was taken; switched to Y." Another program holds the port and the plugin walked forward. Change the port under 连接与证书 (with an availability check) or free it.
"This device's access was removed" (403). The device was revoked or blocked under 已授权设备. Delete the record to let it pair again (a blocked device needs 解除拉黑 first).
I forgot the access password. On the machine that runs DSH, open http://127.0.0.1:3080 (direct loopback access is physically unlocked) and set a new one. On a headless server, delete secrets.json in dataDir and set a new password — until then the gate refuses every device.
Every device needs the password again after I changed it. That is intentional: changing the access password or switching the auth mode revokes every existing visitor session.
Plain HTTP on the LAN is refused. A non-loopback listenHost with tls.mode: 'off' is rejected unless you set tls.allowInsecureLan: true — the gate password would otherwise travel in clear text.
Upgrade
The settings page shows "current version → latest on npm" with a copyable upgrade command:
dsh plugin --profile web add dsh-lan-guard@latest
The plugin installs nothing and restarts nothing — you run the command and then restart DSH once. The check only queries the public npm registry and caches results for six hours; when it cannot reach the registry it says so in the UI and leaves the gate and proxy untouched.
Uninstall
dsh plugin --profile web remove dsh-lan-guard
rm -rf ~/.dsh/profiles/web/data/dsh-lan-guard # optional: removes secrets, device records and the CA
Development
pnpm install
pnpm test # unit + integration tests (includes type checking)
pnpm run build # bundles lib/index.js and lib/client.js
pnpm run verify # typecheck + tests + build + pack dry-run
The client half registers into the official additive settings.section seat; the host half mounts through the package's own cordis.patch.yml.
Release
Releases are tag-driven. Update package.json, move the matching CHANGELOG section out of Unreleased, write release-notes/v<version>.md with both language anchors, then push the release commit and tag:
git tag v0.3.1
git push origin v0.3.1
The release workflow checks that the tag matches the package.json version and that the notes carry both anchors, then runs pnpm run verify, packs the plugin, publishes through npm trusted publishing (OIDC), and creates a GitHub Release with the tarball attached.
License
MIT