# DeepSeek Harness Server Deployment (dsh-server-deployment)
[简体中文](README.md) | [English](README.en.md)
A zero-dependency Node gateway that adds a **multi-user portal** to the web front end of [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH): login authentication, one isolated DSH instance and OS-level data isolation per user, per-user API keys, and a built-in **delivery file drawer** (download / upload / automatic current-workspace detection). Isolation is enforced by the **OS account boundary**, not by gateway code — file access goes through sudo helpers that drop privileges via `runuser` to the `dsh-` user (fixing the issue #1 TOCTOU race), and the gateway itself has zero permissions on user directories. See [docs/multi-user-isolation.md](docs/multi-user-isolation.md) for the full model.
> **Deployment scope (important)**: this project is a **server-side deployment** — the gateway, per-user DSH instances, and file helpers all run on a **remote server**; multiple users access their own sessions and delivery files via a browser (public domain + HTTPS). It is **not a local / desktop tool** and **requires no software installed on users' computers**. Example paths in the docs (e.g. `/opt/deepseek-harness`, `/etc/systemd/system`) are server-side paths.
## Features
- **Login portal**: custom dark login page (lacquer + gold-leaf style), scrypt passwords (with legacy APR1 compatibility), HMAC-signed session cookies (HttpOnly / Secure / SameSite=Lax), login rate limiting (both per-IP and per-account), and double-submit CSRF checks.
- **User isolation**: each user gets a dedicated DSH instance (own port) running as a dedicated system account `dsh-`, with `DSH_HOME` pointing to their 0700 private directory; `userctl.js` provisions / re-passwords / deletes accounts and preloads keys with a single command.
- **Per-user API keys**: users without a key are guided to `/setup` after login; the key is written via loopback RPC into that user's private `.credentials.yaml` (0600, owned only by them).
- **Loopback privileged-endpoint fix**: the gateway presents `Host: 127.0.0.1:` to the backend and strips browser trust markers, so DSH's loopback-pinned privileged endpoints (settings / credentials / agentPreset, …) keep working behind public HTTPS access.
- **Delivery file drawer (file management)**: a single draggable "🗂 文件管理" capsule at the bottom-right of the main UI (merged from the former "交付文件" + "上传文件" pair as of 2026-08), opening a white drawer with an embedded file browser — directory listing, downloads (attachment + non-ASCII filenames), multi-file uploads (100 MB limit); it automatically locates the current conversation's working directory (by sniffing session RPC traces for the cwd, persisted across restarts). The capsule auto-hides while SPA panels/modals are open to avoid occlusion.
- **Security boundary**: all user-file access goes through fixed-path sudo helper scripts — root only validates arguments and drops privileges; file operations execute as the `dsh-` user itself (fixing the issue #1 TOCTOU race). The gateway process has zero permissions on user directories; hidden files (including `.credentials.yaml`) cannot be downloaded; the SPA injection respects `prefers-reduced-motion` and contains no glassmorphism / gradient decoration.
## Architecture
```
Browser ──https──▶ reverse proxy (TLS, e.g. OpenResty) ──▶ dsh-gateway(:3100) ──▶ per-user DSH instance (:3101+)
│ │
│ sessions/throttle/ │ runs as dedicated OS account dsh-
│ CSRF/routing │ DSH_HOME=
│ key setup/drawer │
└──────────┬──────────┘
└─ file access via sudo helpers: dsh-file-{list,stat,read,put}
```
> The gateway's default port is **3100** in the live deployment (this repo's example uses 3081; override with the `PORT` environment variable); per-user instances increment from 3101, allocated by `userctl`.
See [docs/multi-user-isolation.md](docs/multi-user-isolation.md) for the full multi-user and data-isolation story.
## Repository layout
```
gateway/ # the gateway itself (zero-dependency Node)
server.js # login/session/throttle/CSRF/reverse proxy/SPA injection/file drawer/upload-download
auth.js # scrypt + APR1 password verification
credentials.js # .credentials.yaml read/write (userctl only)
userctl.js # user management: OS accounts/ports/instances/keys
_smoke.js # gateway smoke tests (runs locally, no DSH needed)
static/ # pre-login static assets (manifest/favicon)
bin/ # host-side entry points and root helpers
dsh-users.sh # sudo entry for userctl
dsh-file-{list,stat,read,put}[.js]
units/ # systemd unit templates (gateway + per-user units generated by userctl)
nginx/ # TLS reverse-proxy example config (placeholder domain)
```
## Quick deployment (overview)
1. Install DSH (npm package) and prepare a Node runtime; configure the gateway systemd service from `units/` (`User=`, listening on 127.0.0.1 only).
2. Create users with `sudo bin/dsh-users.sh add ` (automatically creates the OS account, allocates a port, generates and starts the instance).
3. Install the root helpers and configure sudoers (fixed-path allowlist):
```bash
install -o root -g root -m 0755 bin/dsh-file-* /opt/deepseek-harness/bin/
# /etc/sudoers.d/dsh-upload:
# ALL=(root) NOPASSWD: /opt/deepseek-harness/bin/dsh-file-put, /opt/deepseek-harness/bin/dsh-file-stat, /opt/deepseek-harness/bin/dsh-file-read, /opt/deepseek-harness/bin/dsh-file-list
```
After upgrades or for self-checks, verify the helpers on the server against this checklist (replace `` with a real username):
```bash
H=/opt/deepseek-harness/users/
sudo -n /opt/deepseek-harness/bin/dsh-file-list "$H" '' # JSON directory listing
printf 'BYTES 6\nhello\n' | sudo -n /opt/deepseek-harness/bin/dsh-file-put "$H" "$H/workspace" t.txt # v2 length protocol; short streams exit=6 and are NOT committed
sudo -n /opt/deepseek-harness/bin/dsh-file-stat "$H" "$H/workspace/t.txt" # prints 6
sudo -n /opt/deepseek-harness/bin/dsh-file-read "$H" "$H/workspace/t.txt" # prints hello
sudo -n /opt/deepseek-harness/bin/dsh-file-read "$H" /etc/passwd; echo "exit=$?" # exit=3 (out-of-scope denied)
ps -ef | grep -E 'runuser.*dsh-' # child processes should be dsh-, not root
```
4. Configure the TLS reverse proxy from `nginx/dsh-https-1145.conf` (replace `server_name` with your domain and mount certificates).
5. On first use after login, users are guided to enter their DeepSeek API key (written only to their private directory).
> Environment variables: both the gateway and userctl let you override the default `/opt/deepseek-harness` prefix:
| Variable | Consumer | Default |
|---|---|---|
| `DSH_BASE_DIR` | installation prefix userctl / dsh-users.sh derives paths from | `/opt/deepseek-harness` |
| `DSH_USERS_DIR`, `DSH_USERS_FILE`, `DSH_SETTINGS_SRC`, `DSH_NODE_BIN`, `DSH_DSH_BIN` | fine-grained userctl overrides | derived from BASE_DIR |
| `USERS_FILE`, `SECRET_FILE`, `USERS_DIR` | gateway | `/opt/deepseek-harness/...` |
| `UPLOAD_HELPER`, `FILE_STAT_HELPER`, `FILE_READ_HELPER`, `FILE_LIST_HELPER` | absolute paths of helpers called by the gateway | `/opt/deepseek-harness/bin/dsh-file-*` (**if you customize the prefix you MUST update sudoers and these four variables in sync**) |
| `HOST`, `PORT`, `SESSION_TTL`, `COOKIE_SECURE`, `DEEPSEEK_BASE_URL`, `UPLOAD_MAX_MB`, `MAX_IP_ATTEMPTS`, `MAX_USER_ATTEMPTS`, `WINDOW_MS`, `LOCK_MS` | gateway | see `gateway/server.js` |
| `DSH_TRUSTED_HOST` | userctl (instance `--trusted-host`) | `127.0.0.1:1145` |
`bin/dsh-users.sh` and `bin/dsh-file-list` locate themselves relative to their own path: any checkout directory works as-is (`dsh-users.sh` auto-re-privileges to root on first call; node resolves relative to the script location, falling back to `PATH`). When using a custom installation prefix, generate the systemd units with the `sed` command above; the gateway systemd unit also supports `EnvironmentFile=-/etc/default/dsh-gateway` for injecting the environment variables above in one place.
## Delivery file drawer — behavioral details
- **Auto-location**: the gateway sniffs proxied traffic for `session.history` (opening a session) and `session.list` (cwd per session), remembers the current conversation directory and persists it to `state-cwd.json`; opening "文件管理" lists that directory (falling back to the workspace if it no longer exists).
- **Embedding & closing**: the drawer is embedded as a same-origin iframe (`X-Frame-Options: SAMEORIGIN`); the in-page "back to app" control detects the iframe environment at runtime and sends `postMessage('dshgw-close')` to close the drawer instead of navigating, preventing nested drawers.
- **Uploads**: raw-byte body `POST /__gw/upload?dir=&name=`; the helper drops privileges to `dsh-` before writing (root only validates arguments), so file ownership is naturally the user's own; same-name files are overwritten; over-limit uploads get 413.
## Security notes
- **Install-tree integrity (the most critical item)**: the entire `/opt/deepseek-harness` tree — including the DSH monorepo sources (`packages/`, `apps/`, `node_modules/`) and the `.agents/` skill library — must carry no group/other write bits. All tenant instances **share and execute** this code, so any writable point is a cross-tenant injection vector (modify shared code or skill files → execute as another tenant → steal their API key). After every deploy/upgrade, self-check: `find /opt/deepseek-harness -not -path '*/users*' -perm /022 | wc -l` must print 0 (the `users/` user directories are the exception).
- Keep `gateway/` at `root:dsh-gateway 0770`: the gateway needs tmp+rename atomic writes inside it (users.json / secret / state-cwd.json); code files inside the directory (server/userctl/auth/credentials/static) are root:root 0644. Everything in `bin/` is root:root.
- The gateway runs as a dedicated system account `dsh-gateway` (no shell). **Never** run the gateway under a cloud-image account like `ubuntu` that ships NOPASSWD sudo; its sudo capability must be limited to the four file helpers allowlisted in `/etc/sudoers.d/dsh-upload`. The gateway systemd unit must **not** set `NoNewPrivileges=yes` (it blocks sudo to the root helpers, breaking upload/download/listing entirely).
- **Loopback tenant isolation** (`bin/dsh-loopback-guard` + `units/dsh-loopback-guard.service`): DSH instances authorize privileged endpoints by "the Host header is loopback", yet all instances share 127.0.0.1 — any tenant's agent can forge the Host header and hit another tenant's port directly to steal their API key. The mitigation is an iptables OUTPUT chain: each `dsh-` may connect only to its own instance port; other tenant ports and the gateway port are REJECTed, while root/gateway accounts are unaffected. userctl refreshes the rules automatically on add/delete; rules REJECT **per destination port** (do not blanket-reject by uid — that would kill the kernel's reply path).
- Per-user instances carry systemd resource limits (TasksMax/MemoryMax/CPUQuota, tunable via `DSH_MEM_MAX`/`DSH_CPU_QUOTA`) and kernel-hardening directives.
- File helpers (dsh-file-put/read/stat/list): root only string-validates arguments and switches identity; all file operations run via `runuser -u dsh-` as the user themselves (fixing the issue #1 TOCTOU race); the realpath prefix checks inside the helpers are kept only for exit-code semantics and are no longer the security boundary. Depends on `runuser` from util-linux. **The upload helper uses the v2 length protocol** (`BYTES ` header + exact byte-count verification): the gateway streams the request body through, and aborted/timed-out/over-limit uploads are refused with exit 6 — no truncated file is ever committed.
- User credential files must remain owner-readable only (0600): DSH enforces this at startup (`assertOwnerOnly`). The root-helper model satisfies this naturally; do not add any ACL read grants to user directories (this once caused instances to refuse to start).
- The gateway and all instances listen on 127.0.0.1 only; the public internet sees only the TLS reverse proxy. The proxy must **overwrite** (not append to) `X-Forwarded-For` with `$remote_addr` (the `nginx/dsh-https-1145.conf` template ships with this safe default) — otherwise an attacker can forge the XFF chain and bypass the gateway's IP-level rate limiting.
- Login rate limiting operates at both IP and account level; account lockout (5 attempts / 15 minutes) can itself be abused for DoS and is mitigated only by IP-level throttling and strong passwords. Changing a password bumps `pwdVer`, immediately invalidating every previously issued session (including old unversioned tokens).
- `/logout` accepts POST only (with double-submit CSRF verification), preventing cross-site logout.
- If you need client-behavior patches after upgrading DSH (e.g. settings persistence scope), evaluate them yourself; this repository does not modify the npm package.
- Microsoft's password autofill can cause problems with the left-hand workspace directory listing; keep autofill disabled where possible.
## License
[MIT](LICENSE)