|
| 1 | +# 🐾 deepseek-harness-lan |
| 2 | + |
| 3 | +**English** | [简体中文](README.zh-CN.md) |
| 4 | + |
| 5 | +> Run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI on your home LAN — bind a specific IP, trust it, and open it from any device. |
| 6 | +
|
| 7 | + |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | +> **🎯 Target version: dsh `0.1.0-rc.5` (commit `47f9438`)** — the patch is written and verified against this version; other versions may break (see [🧩 Compatibility](#-compatibility)). |
| 12 | +
|
| 13 | +--- |
| 14 | + |
| 15 | +## 📖 What is this |
| 16 | + |
| 17 | +dsh's Web UI is **officially restricted to localhost access only**. `--host 0.0.0.0` fails immediately: |
| 18 | + |
| 19 | +``` |
| 20 | +error: --host 0.0.0.0 is intentionally not supported yet for safety: |
| 21 | +it would expose remote code execution to the network; use 127.0.0.1 instead |
| 22 | +``` |
| 23 | + |
| 24 | +This is a **deliberate security design** — the dsh Web UI sits on top of an agent that can execute arbitrary commands, and the official project does not want it exposed to the network. |
| 25 | + |
| 26 | +But what if you want to open dsh from **Windows, phones, or tablets** on your home/office LAN? This repository provides **4 minimal patches + one-click scripts** that let you bind a **specific LAN IP** and run the dsh Web UI safely. |
| 27 | + |
| 28 | +## ✨ Features |
| 29 | + |
| 30 | +- 🔧 **4 minimal diffs** (81 lines) — only the necessary source changes, no extra dependencies |
| 31 | +- 📦 **One-click apply / revert** (`apply.sh` / `revert.sh`) — auto-detection, duplicate-application guard |
| 32 | +- 🧩 **Does not break the official security model**: the `0.0.0.0` wildcard is still rejected; the `--trusted-host` explicit trust mechanism is preserved |
| 33 | +- 🧪 **Verified on dsh 0.1.0-rc.5**: page load, API calls, and privileged endpoints all pass |
| 34 | +- 🪄 **Auto-installs the missing build dependency** (`unrun`) so `pnpm run build` doesn't fail mysteriously |
| 35 | + |
| 36 | +## 🚀 Quick Start |
| 37 | + |
| 38 | +### From zero to running (6 steps) |
| 39 | + |
| 40 | +The core order in one sentence: **install the dsh source first, then run `apply.sh`** — `apply.sh` is a patching tool, not a dsh installer. |
| 41 | + |
| 42 | +#### ① Prepare the environment |
| 43 | + |
| 44 | +- Node.js **≥ 24** (verify with `node -v`) |
| 45 | +- pnpm (verify with `pnpm -v`) |
| 46 | +- git |
| 47 | +- 🪟 Windows users: run the bash scripts in **Git Bash or WSL**; the dsh source itself is cross-platform |
| 48 | + |
| 49 | +#### ② Get the dsh source |
| 50 | + |
| 51 | +```bash |
| 52 | +git clone https://github.com/deepseek-ai/deepseek-harness.git |
| 53 | +cd deepseek-harness |
| 54 | +pnpm install |
| 55 | +``` |
| 56 | + |
| 57 | +> ⚠️ **Must be a `git clone` checkout** — the packaged `npm install` build won't work, because the patch modifies source files. |
| 58 | +> ⚠️ **Do not use a shallow clone (`--depth 1`)** — the version pinning below needs the full history. |
| 59 | +> |
| 60 | +> The current master branch is the target version `0.1.0-rc.5` (commit `47f9438`). If upstream has moved on (`apply.sh` will warn about a version mismatch), pin the version first: |
| 61 | +> |
| 62 | +> ```bash |
| 63 | +> git checkout 47f9438 |
| 64 | +> pnpm install |
| 65 | +> ``` |
| 66 | +
|
| 67 | +#### ③ Clone deepseek-harness-lan and apply the patch |
| 68 | +
|
| 69 | +```bash |
| 70 | +git clone https://github.com/oitsukiii/deepseek-harness-lan.git |
| 71 | +cd deepseek-harness-lan |
| 72 | +./apply.sh /path/to/deepseek-harness |
| 73 | +``` |
| 74 | +
|
| 75 | +`apply.sh` automatically: version check → verify the patch applies cleanly → apply → install the missing build dependency `unrun`. You're done when you see **`✓ 补丁应用完成`** (patch applied). |
| 76 | + |
| 77 | +> 💡 **Directory-independent**: `deepseek-harness-lan` and `deepseek-harness` can live in any directories (any combination) — the script locates its own patch and the dsh checkout by argument, with no hardcoded paths. Three ways to run: pass the dsh path as an argument, `cd` into the dsh directory and run, or put deepseek-harness-lan next to dsh. |
| 78 | +
|
| 79 | +#### ④ Rebuild the frontend |
| 80 | + |
| 81 | +```bash |
| 82 | +cd /path/to/deepseek-harness |
| 83 | +pnpm run build:web |
| 84 | +``` |
| 85 | + |
| 86 | +> The randomUUID polyfill lives in the web bundle, **you must rebuild for it to take effect**. |
| 87 | +
|
| 88 | +#### ⑤ Start and bind a LAN IP |
| 89 | + |
| 90 | +```bash |
| 91 | +pnpm dsh web --host 192.168.1.100 --trusted-host 192.168.1.100 |
| 92 | +``` |
| 93 | + |
| 94 | +Replace `192.168.1.100` with your LAN IP (check `ip addr` or your router's admin page). |
| 95 | + |
| 96 | +#### ⑥ Access and verify |
| 97 | + |
| 98 | +On another device (Windows / phone / tablet), open in a browser: |
| 99 | + |
| 100 | +``` |
| 101 | +http://192.168.1.100:3080 |
| 102 | +``` |
| 103 | + |
| 104 | +> ⚠️ If you opened it before, do a **Ctrl + F5 hard refresh** (the browser may cache the old JS). |
| 105 | +
|
| 106 | +**Verification checklist:** |
| 107 | + |
| 108 | +- [ ] The page loads normally, title "DeepSeek Harness" |
| 109 | +- [ ] Configuring a model API does not report 403 / `crypto.randomUUID is not a function` |
| 110 | +- [ ] Other devices on the LAN can open it too |
| 111 | + |
| 112 | +#### Revert (optional) |
| 113 | + |
| 114 | +```bash |
| 115 | +./revert.sh /path/to/deepseek-harness |
| 116 | +``` |
| 117 | + |
| 118 | +Removes the patches with one command; afterwards you can `git pull` to update dsh normally. |
| 119 | + |
| 120 | +--- |
| 121 | + |
| 122 | +## 🔀 Alternative: no source changes (SSH tunnel) |
| 123 | + |
| 124 | +If you **don't want to modify the dsh source** (keeping it pristine for zero-maintenance official upgrades), an SSH tunnel is the only clean LAN access method — **zero patches, all three gates pass naturally**. |
| 125 | + |
| 126 | +### Why it works without patching |
| 127 | + |
| 128 | +dsh's trust check (browser-trust) **looks at the request's Host header, not the source IP** (DNS rebinding defense); and `crypto.randomUUID` is only available in a secure context (HTTPS or localhost). An SSH tunnel makes the browser always access dsh via `127.0.0.1`, so: |
| 129 | + |
| 130 | +| Three gates | Under SSH tunnel | Why | |
| 131 | +|---|---|---| |
| 132 | +| Gate 1 (CLI/schema) | ✅ not triggered | dsh listens on `127.0.0.1` = official default, no changes needed | |
| 133 | +| Gate 2 (browser-trust) | ✅ includes privileged endpoints | Host header = `127.0.0.1` → loopback exemption (the original privileged endpoints only allow loopback anyway) | |
| 134 | +| Gate 3 (randomUUID) | ✅ available | `127.0.0.1` is a secure context; the browser provides it natively | |
| 135 | + |
| 136 | +### Steps |
| 137 | + |
| 138 | +```bash |
| 139 | +# 1. Start dsh as official (no patch, listening on 127.0.0.1:3080) |
| 140 | +cd /path/to/deepseek-harness |
| 141 | +pnpm dsh web |
| 142 | +``` |
| 143 | + |
| 144 | +On the computer you want to access from, open the tunnel (Windows 10/11 ships OpenSSH): |
| 145 | + |
| 146 | +```powershell |
| 147 | +ssh -N -L 3080:127.0.0.1:3080 <user>@<NAS-IP> |
| 148 | +``` |
| 149 | + |
| 150 | +Open in the browser: |
| 151 | + |
| 152 | +``` |
| 153 | +http://127.0.0.1:3080 |
| 154 | +``` |
| 155 | + |
| 156 | +API configuration, privileged endpoints, and all features work normally. |
| 157 | + |
| 158 | +### Comparison |
| 159 | + |
| 160 | +| Option | Source changes | Computer | Phone/tablet | Official upgrades | |
| 161 | +|---|---|---|---|---| |
| 162 | +| **SSH tunnel** (this option) | ❌ zero changes | ✅ | ⚠️ configure a tunnel per device | direct `git pull`, seamless | |
| 163 | +| **deepseek-harness-lan patch** (main option) | ✅ 4 minimal diffs | ✅ | ✅ open the URL directly | need to re-apply the patch | |
| 164 | + |
| 165 | +### Why nginx reverse proxy doesn't work |
| 166 | + |
| 167 | +After a reverse proxy (HTTP or HTTPS), the Host header becomes the LAN IP, while the **original privileged endpoints** (`settings.describe`, `llm.providers`, etc. — required for configuring model APIs) use `isTrustedApiRequest(request, [])` which only allows loopback, so the configuration API step necessarily returns 403. The SSH tunnel is the only "no source changes + full functionality" path; if you want every device to open a URL directly, use the main patch option. |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +## 🧠 How it works |
| 172 | + |
| 173 | +dsh officially blocks LAN access via **three gates**. The project's 4 patches break through them one by one: |
| 174 | + |
| 175 | +### Gate 1: CLI rejects `0.0.0.0`, schema only accepts two literals |
| 176 | + |
| 177 | +**Symptom**: `--host 0.0.0.0` fails immediately; binding a specific IP (e.g. `192.168.1.100`) also fails to start: |
| 178 | + |
| 179 | +``` |
| 180 | +ValidationError: invalid config: |
| 181 | + - $.host expected "127.0.0.1" | "0.0.0.0" but got "192.168.1.100" (at host) |
| 182 | +``` |
| 183 | + |
| 184 | +**Cause** (two layers of restrictions): |
| 185 | + |
| 186 | +| Layer | Location | Restriction | |
| 187 | +|---|---|---| |
| 188 | +| CLI layer | `packages/bundle/web-app/src/startup.ts` | `options.host === '0.0.0.0'` triggers `program.error()` and refuses to start | |
| 189 | +| Schema layer | `packages/host/webserver/src/index.ts` | zod validates `z.union([z.const('127.0.0.1'), z.const('0.0.0.0')])`, and the type definition only allows those two values | |
| 190 | + |
| 191 | +**Patch**: |
| 192 | + |
| 193 | +```diff |
| 194 | +// packages/host/webserver/src/index.ts |
| 195 | +- host: '127.0.0.1' | '0.0.0.0' // type |
| 196 | ++ host: string |
| 197 | +- host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required() // zod |
| 198 | ++ host: z.string().required() |
| 199 | +``` |
| 200 | + |
| 201 | +```diff |
| 202 | +// packages/bundle/web-app/src/startup.ts |
| 203 | +- if (options.host === '0.0.0.0') { |
| 204 | +- program.error('...intentionally not supported yet for safety...') |
| 205 | +- } |
| 206 | ++ // only reject the 0.0.0.0 wildcard; specific LAN IPs are allowed |
| 207 | +``` |
| 208 | + |
| 209 | +**Design trade-off**: the `0.0.0.0` wildcard binds to every interface (including public/WG), which is dangerous; binding a **specific IP** exposes only one interface and stays controllable. So only specific IPs are unlocked — the wildcard stays rejected. |
| 210 | + |
| 211 | +### Gate 2: the /api browser-trust fence (403) |
| 212 | + |
| 213 | +**Symptom**: the page opens, but every `/api/*` request returns `HTTP 403`, e.g.: |
| 214 | + |
| 215 | +``` |
| 216 | +加载提供方目录失败: transport failure for /api/llm.providers: HTTP 403 |
| 217 | +``` |
| 218 | + |
| 219 | +**Cause**: there is a **browser-trust fence** in front of dsh's `/api` gateway (anti DNS-rebinding / cross-site attack) in `packages/client/connection/src/index.ts`: |
| 220 | + |
| 221 | +```ts |
| 222 | +// trust list: only when binding 0.0.0.0 does it collect all LAN IPs |
| 223 | +// binding a specific IP → empty list → all non-localhost requests 403 |
| 224 | +const lanAddresses = bindHost === ALL_INTERFACES_HOST ? collectLanIps() : [] |
| 225 | +return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } |
| 226 | +``` |
| 227 | + |
| 228 | +Additionally, **privileged methods** (`settings.*`, `credentials.*`, `agentPreset.*`, etc.) are checked against an **empty trust list** — meaning even with `--trusted-host`, those endpoints still only allow localhost: |
| 229 | + |
| 230 | +```ts |
| 231 | +if (PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) { |
| 232 | + return new Response('forbidden', { status: 403 }) |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +**Patch** (`packages/client/connection/src/index.ts`): |
| 237 | + |
| 238 | +```diff |
| 239 | +- && !isTrustedApiRequest(request, [])) { // empty list: loopback only |
| 240 | ++ && !isTrustedApiRequest(request, trustedHosts)) { // explicit trust takes effect |
| 241 | +``` |
| 242 | + |
| 243 | +**Design trade-off**: `--trusted-host` (the admin-configured explicit trust entry) now also applies to privileged endpoints. **Without `--trusted-host`, behavior is unchanged** — still loopback-only, so the official security semantics are preserved. |
| 244 | + |
| 245 | +### Gate 3: `crypto.randomUUID is not a function` |
| 246 | + |
| 247 | +**Symptom**: error when configuring APIs: |
| 248 | + |
| 249 | +``` |
| 250 | +加载提供方目录失败: crypto.randomUUID is not a function |
| 251 | +``` |
| 252 | + |
| 253 | +**Cause**: the browser Web Crypto API's `crypto.randomUUID()` is **only available in a secure context (HTTPS or localhost)**. When accessing `http://192.168.1.100:3080` (plaintext HTTP on the LAN), the function doesn't exist. |
| 254 | + |
| 255 | +**Patch** (`apps/web/src/main.ts`, at the very top of the web entry): |
| 256 | + |
| 257 | +```ts |
| 258 | +// crypto.randomUUID is only available in secure contexts; add a UUIDv4 |
| 259 | +// implementation for plain-HTTP LAN environments |
| 260 | +if (typeof globalThis.crypto === 'object' && typeof globalThis.crypto.randomUUID !== 'function') { |
| 261 | + try { |
| 262 | + globalThis.crypto.randomUUID = () => |
| 263 | + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { |
| 264 | + const r = (Math.random() * 16) | 0 |
| 265 | + const v = c === 'x' ? r : (r & 0x3) | 0x8 |
| 266 | + return v.toString(16) |
| 267 | + }) |
| 268 | + } catch { /* keep as-is in read-only crypto environments */ } |
| 269 | +} |
| 270 | +``` |
| 271 | + |
| 272 | +> 💡 The UUID is only used for local identifiers (attachment drafts, session ids, etc.) and is not involved in security checks, so the polyfill has no security risk. |
| 273 | +
|
| 274 | +--- |
| 275 | + |
| 276 | +## 🔒 Security notes (please read) |
| 277 | + |
| 278 | +- ⚠️ **This UI can execute arbitrary commands**. After patching, **any device on the LAN** can access and control your agent. |
| 279 | +- ✅ Only use it on a **trusted home/office network**. |
| 280 | +- ❌ **Do not** port-forward 3080 to the public internet (port forwarding / DMZ). |
| 281 | +- ❌ **Do not** use `--host 0.0.0.0` (still rejected after patching — a deliberately kept line of defense). |
| 282 | +- 💡 Advanced: put an **authenticating reverse proxy** (e.g. nginx Basic Auth) in front of dsh. |
| 283 | +- 🔐 `--trusted-host` is the official browser-trust mechanism's explicit trust entry — only fill in your own LAN IP. |
| 284 | + |
| 285 | +## 🧩 Compatibility |
| 286 | + |
| 287 | +**This patch is written and verified against dsh `0.1.0-rc.5` (commit `47f9438`).** |
| 288 | + |
| 289 | +- Verification chain: patch applied → `pnpm run build:web` build → Web UI page load → `/api` calls → privileged endpoints (`settings.describe`, etc.) all pass. |
| 290 | +- dsh upstream iterates fast; **other versions will most likely break**: |
| 291 | + - When the source context changes, `apply.sh`'s `git apply --check` **fails and aborts safely** without dirtying your repo (this is a protection mechanism, not a bug). |
| 292 | +- **Adapting to new versions**: |
| 293 | + 1. Run `apply.sh` on the new version and note the conflicting files when it fails; |
| 294 | + 2. Manually adapt by following the "three gates" approach in [🧠 How it works](#-how-it-works); |
| 295 | + 3. PRs with adapted patches are welcome. |
| 296 | +- **Revert**: `revert.sh` removes the patches in one command; afterwards you can `git pull` to update dsh normally. |
| 297 | + |
| 298 | +## ❓ FAQ |
| 299 | + |
| 300 | +**Q: Can I access it from my phone?** |
| 301 | +A: Yes. As long as the phone is on the same LAN, open `http://<NAS-IP>:3080` in the browser. |
| 302 | + |
| 303 | +**Q: What happens when dsh updates officially after patching?** |
| 304 | +A: `apply.sh` runs `git apply --check` first; if the upstream code changed, it fails and aborts without dirtying your repo. Revert with `revert.sh`, then `git pull` normally. |
| 305 | + |
| 306 | +**Q: Why not run it with Docker?** |
| 307 | +A: dsh is officially distributed as a Node.js / npm package with no official Docker image; this project only modifies source, so following the official install path is the most stable. |
| 308 | + |
| 309 | +**Q: Will the official project accept this patch?** |
| 310 | +A: The official project **deliberately** blocks wildcard binding and is unlikely to relax it soon; but the "bind a specific IP + explicit trust" idea is worth an issue/discussion upstream. |
| 311 | + |
| 312 | +## 🧩 Patch files |
| 313 | + |
| 314 | +| File | Addresses | |
| 315 | +|---|---| |
| 316 | +| `patches/deepseek-harness-lan.patch` | unified diff of all 4 changes (use directly with `git apply`) | |
| 317 | + |
| 318 | +## 🤝 Contributing |
| 319 | + |
| 320 | +- Bugs / compatibility issues: open an issue with the dsh version + error message |
| 321 | +- New platform / new version adaptation: open a PR, update the patch and verify on the target version |
| 322 | +- Patch invalidated by upstream dsh updates: PRs with updated patches are welcome |
| 323 | + |
| 324 | +## 🎉 Acknowledgements |
| 325 | + |
| 326 | +- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — an excellent agent framework ("Everything is a Plugin") |
| 327 | +- This project is developed with **DeepSeek V4 Flash** (troubleshooting → locating → patching → scripting → documentation, the whole flow) |
| 328 | + |
| 329 | +## 📜 License |
| 330 | + |
| 331 | +MIT License — an unofficial community project unrelated to DeepSeek Harness, for learning and personal reference only. |
0 commit comments