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.
## 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:
```sh
dsh plugin --profile web add dsh-lan-guard@latest
```
Install from GitHub:
```sh
dsh plugin --profile web add "github:idoall/dsh-lan-guard"
```
Install from a local clone:
```sh
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`, `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`. |
The plugin reads its config from its Cordis entry. **Every key has a usable default, so a fresh install works as-is:**
```yaml
# ~/.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 `/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.
## 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](CHANGELOG.md).
The official UI is reused with zero modifications and adapts on a phone viewport:
## 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:
```sh
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
```sh
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
```sh
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.md` with both language anchors, then push the release commit and tag:
```sh
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](LICENSE)