DeepSeek Harness Server Deployment (dsh-server-deployment)
August 25, 2026 · View on GitHub
A zero-dependency Node gateway that adds a multi-user portal to the web front end of 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-<name> user (fixing the issue #1 TOCTOU race), and the gateway itself has zero permissions on user directories. See 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-<name>, withDSH_HOMEpointing to their 0700 private directory;userctl.jsprovisions / re-passwords / deletes accounts and preloads keys with a single command. - Per-user API keys: users without a key are guided to
/setupafter 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:<port>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-<name>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 respectsprefers-reduced-motionand 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-<name>
│ CSRF/routing │ DSH_HOME=<user-private 0700 dir>
│ key setup/drawer │
└──────────┬──────────┘
└─ file access via sudo helpers: dsh-file-{list,stat,read,put}
The gateway's default port is 3100 (the
server.jsdefault, the systemd unit and the nginx example are now aligned; override with thePORTenvironment variable — if you do, also update the reverse proxy; the loopback guard auto-reads the port the gateway persists tostate-port.jsonat startup, soGW_PORTneeds no manual change); per-user instances increment from 3101, allocated byuserctl.
See 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)
store.js # users.json optimistic-concurrency read-modify-write (shared by gateway and userctl)
userctl.js # user management: OS accounts/ports/instances/keys
_smoke.js # gateway smoke tests (runs locally, no DSH needed)
_unit.js # pure-logic unit tests (node gateway/_unit.js, no root 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)
-
Install DSH (npm package) and prepare a Node runtime; configure the gateway systemd service from
units/(User=<service account>, listening on 127.0.0.1 only). -
Create users with
sudo bin/dsh-users.sh add <user>(automatically creates the OS account, allocates a port, generates and starts the instance). -
Install the root helpers and configure sudoers (fixed-path allowlist):
install -o root -g root -m 0755 bin/dsh-file-* /opt/deepseek-harness/bin/ # /etc/sudoers.d/dsh-upload: # <service-account> 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 # Only needed when raising the upload cap (helper default: 110000000 bytes ≈ 105MB): # Defaults!/opt/deepseek-harness/bin/dsh-file-put env_keep += "DSH_UPLOAD_MAX_BYTES"After upgrades or for self-checks, verify the helpers on the server against this checklist (replace
<user>with a real username):H=/opt/deepseek-harness/users/<user> 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-<user>, not root -
Configure the TLS reverse proxy from
nginx/dsh-https-1145.conf(replaceserver_namewith your domain and mount certificates). -
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-harnessprefix:
| 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 (default 3100), SESSION_TTL, COOKIE_SECURE, DEEPSEEK_BASE_URL, UPLOAD_MAX_MB, MAX_IP_ATTEMPTS, MAX_USER_ATTEMPTS, WINDOW_MS, LOCK_MS, SNIFF_BUFFER_CONCURRENCY (max concurrently buffered history responses, default 4) | gateway | see gateway/server.js |
DSH_UPLOAD_MAX_BYTES | dsh-file-put (root helper, passed through sudoers env_keep) | 110000000 (raise together with UPLOAD_MAX_MB) |
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) andsession.list(cwd per session), remembers the current conversation directory and persists it tostate-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 sendspostMessage('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 todsh-<name>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-harnesstree — 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 -lmust print 0 (theusers/user directories are the exception). - Keep
gateway/atroot: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 inbin/is root:root. - The gateway runs as a dedicated system account
dsh-gateway(no shell). Never run the gateway under a cloud-image account likeubuntuthat 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 setNoNewPrivileges=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: eachdsh-<name>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-<name>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 onrunuserfrom util-linux. The upload helper uses the v2 length protocol (BYTES <n>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-Forwith$remote_addr(thenginx/dsh-https-1145.conftemplate 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). /logoutaccepts 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.