Back to home

vocsong

deepseek-harness-portal

A multi-tenant platform that provisions and routes isolated DeepSeek Harness (dsh) instances — one container per user.

Stars
1
Language
JavaScript
Created
Aug 16, 2026
Updated
Aug 16, 2026

Introduction

DeepSeek Harness Portal

A multi-tenant platform that provisions and routes isolated DeepSeek Harness (dsh) instances — one container per user — behind a single Cloudflare Tunnel.

Internet
  │  <your-domain>                    (portal: login, admin, user dashboard)
  │  <slug>.<instance-domain>         (one instance per user)
  ▼
Cloudflare Tunnel (cloudflared, one named tunnel)
  │
  ▼
Portal (Node + Fastify + SQLite) ── auth + reverse proxy + orchestrator
  │
  ├─► container dsh-<slug>   (dsh web on :3000 inside, published 127.0.0.1:18xxx)
  ├─► container dsh-<slug2>  ...
  └─► ...

The portal is the only authentication entry point. It owns registration, login, roles, and per-instance access control.

Authentication model

  • Registration: email + one-time code (OTP), with optional username + password set during signup (so a new user can log in with credentials right away). An invite code is enforced when the admin has set one.
  • After registering: Profile can change name, username, password, and email. Email replacement requires separate codes from both the current and proposed mailboxes and revokes all other sessions.
  • Login: username/password or "email me a code" (OTP fallback).
  • Admin controls (Settings tab): email-domain whitelist, invite code, toggle OTP registration, toggle password login.

OTP delivery uses SMTP (nodemailer), which is required in production. Code logging is available only with the explicit localhost-only development combination NODE_ENV=development, DOMAIN=localhost, and OTP_DEV_MODE=true; OTP values are never returned by the API.

Architecture

ComponentLocationRole
Portal appportal/Fastify server: auth (OTP + password, bcrypt, session cookie), admin/user JSON API, static dashboard, subdomain reverse proxy
Orchestratorportal/src/orchestrator.jspodman CLI wrappers: run/start/stop/rm/logs, port allocation, health polling, background provisioning
Storageportal/data/portal.dbSQLite (better-sqlite3): users, otps, settings, instances, sessions
dsh imageimage/DockerfileBuilds the reviewed dsh image from an approved upstream commit, a dependency-security patch, and two documented source patches; tagged dsh:47f9438-node24 (Node 24 LTS on Debian 13 trixie) and deployed by its sha256: digest
dsh clonedsh/Fresh deepseek-harness checkout (build context, gitignored)

Per-instance model

Each user gets exactly one instance (1 user : 1 instance):

  • Container dsh-<slug>, CPU/memory/swap/PID limits, read-only root + bounded tmpfs/logs, no capabilities, no-new-privileges, explicit isolated pasta networking, --restart unless-stopped
  • Two volumes: <name>-home (mounted at /home/dsh — the user's writable home, including $DSH_HOME=/home/dsh/.dsh for sessions, settings, and credentials) and <name>-workspace (mounted at /workspace — the agent's persistent cwd)
  • Published on 127.0.0.1:<port> (never exposed beyond the host); the portal proxies to it
  • The proxy presents traffic to the instance as loopback (changeOrigin rewrites Host to 127.0.0.1:<port> and the browser's Origin is dropped) so dsh's loopback-only settings/credentials methods work; TRUSTED_HOST=<slug>.<instanceDomain> is still passed as a fallback
  • The user's DeepSeek API key is entered inside their instance (Settings → Models) and lives only in that instance's home volume

Build-time changes to dsh (see image/Dockerfile)

  • image/dsh-security.patch applies reviewed transitive dependency floors and a matching frozen lockfile (production audit: zero known advisories at review time).
  • Identity opener → "You are an AI agent." (branding).
  • Allow --host 0.0.0.0 → required so the published port reaches dsh inside the container (its CLI refuses 0.0.0.0 by default for LAN safety; inside the container only the loopback-published port is reachable).

The build script requires the exact approved upstream commit and fails if source/patch checks drift.

Subdomains, not paths

Instances use one-level subdomains (<slug>.<instance-domain>), not <your-domain>/<slug>. Two reasons:

  1. dsh's SPA calls /api and loads /assets with absolute paths baked at build time — path-based routing would require rewriting HTML and intercepting the SPA's absolute /api calls (fragile with multiple tabs).
  2. Cloudflare's free-tier Universal SSL wildcard covers only one label (*.example.com), so <slug>.<sub>.<your-domain> has no valid TLS certificate. A -deepseek slug suffix keeps the brand while staying one label.

Prerequisites

  • Podman with a running machine: podman machine start <machine-name>
  • Node.js 22+ and npm
  • cloudflared (cloudflared tunnel --version)
  • A Cloudflare zone on your account, with the tunnel already authenticated

Setup

1. Clone dsh (fresh upstream)

git clone --depth 1 https://github.com/deepseek-ai/deepseek-harness.git dsh
git -C dsh fetch --depth 1 origin 47f943859bef60e4160492346772ded9b24f765a
git -C dsh checkout --detach 47f943859bef60e4160492346772ded9b24f765a

2. Build the dsh image

./build-image.sh

Always use the script: it verifies the approved commit and patch, validates the context policy, and builds from a clean git archive. A direct podman build bypasses those controls. Copy the script's printed DSH_IMAGE=sha256:... line into .env; production rejects mutable image tags.

First build is slow (pnpm install + full harness build); result is ~2.5 GB.

3. Install portal deps

cd portal && npm install && cd ..

4. Configure

cp .env.example .env   # then edit .env (DOMAIN, INSTANCE_DOMAIN, ADMIN_*, SMTP_*)

5. Cloudflare routing (once)

cloudflared tunnel route dns <tunnel-name> '*.<instance-domain>'
cloudflared tunnel route dns <tunnel-name> <your-domain>

Tunnel config (~/.cloudflared/<tunnel-name>.yml):

tunnel: <tunnel-id>
credentials-file: '~/.cloudflared/<tunnel-id>.json'
ingress:
  - hostname: <your-domain>
    service: http://127.0.0.1:8080
  - hostname: '*.<instance-domain>'
    service: http://127.0.0.1:8080
  - service: http_status:404
cloudflared tunnel --config ~/.cloudflared/<tunnel-name>.yml run <tunnel-name>

6. Run the portal

./run-portal.sh

Seeds an admin account on first boot from ADMIN_EMAIL / ADMIN_NAME / ADMIN_PASSWORD. The password must be explicitly set, non-placeholder, and at least 16 characters; startup refuses an unsafe bootstrap.

run-portal.sh first applies the tenant egress firewall (firewall/apply.sh) and fails closed if the Podman machine is not running.

Configuration (environment variables)

See .env.example. The important ones:

VarDefaultMeaning
NODE_ENVproductionUse development only for localhost testing
DOMAINexample.comPortal apex domain
PORTAL_ORIGINhttps://<DOMAIN>Exact trusted browser origin for mutation/CSRF checks
INSTANCE_DOMAINexample.comBase for <slug>.<instanceDomain>
INSTANCE_SLUG_SUFFIX-deepseekAppended to the slug
COOKIE_DOMAIN(empty)Session cookie domain (must cover apex + instances)
SESSION_ABSOLUTE_TTL_MS / SESSION_IDLE_TTL_MS7 days / 24 hoursServer-enforced session lifetime and idle expiry
PORT8080Portal listen port (cloudflared connects here)
ADMIN_EMAIL / ADMIN_NAME / ADMIN_PASSWORD(empty)First-boot admin; password must be explicit and at least 16 characters
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM(empty)Production OTP delivery; host/from required, auth values paired
OTP_TTL_MS / OTP_MAX_ATTEMPTS10 min / 5OTP lifetime and per-code attempt cap
AUTH_RATE_WINDOW_MS / AUTH_RATE_BLOCK_MS15 min / 15 minPersistent authentication throttling window/block
OTP_RESEND_COOLDOWN_MS60000Minimum interval between OTP requests per address/purpose
PORT_RANGE_START / END18000 / 18100Host loopback port pool
DSH_IMAGErequiredImmutable sha256:... image ID printed by ./build-image.sh; mutable tags are rejected in production
PODMAN_COMMAND_TIMEOUT_MS60000Per-subprocess timeout for every podman call
INSTANCE_CPUS / INSTANCE_MEMORY / INSTANCE_MEMORY_SWAP2 / 2g / 2gPer-instance compute limits
INSTANCE_PIDS_LIMIT512Per-container process limit
INSTANCE_NETWORKpastaRequired isolated rootless network mode
INSTANCE_LOG_SIZE / INSTANCE_TMPFS_SIZE10mb / 64mBounded runtime log and temporary storage
INSTANCE_READ_ONLY_ROOTtrueRun tenant containers with a read-only root filesystem

Testing

cd portal && npm test

Runs the isolated transactional regression suite (email-change proof binding, attempt accounting, and uniqueness/session rollback) against a temporary SQLite database in portal/test/. It does not touch live data, send email, or require Podman.

Operations

  • Admin: log in as admin → Settings tab → email-domain whitelist, invite code, auth toggles. Instances tab → reprovision/delete, view logs, usage (requests + last-active). Users tab → list users.
  • User: register (email OTP, optionally setting username/password) → instance auto-provisions → dashboard shows URL + status. Instances auto-start on launch and auto-stop after the idle timeout.
  • Launch: https://<slug>.<instance-domain> — the portal authenticates + authorizes, then proxies to the container.
  • API key: set per-instance inside dsh (Settings → Models).

Restart after a reboot

podman machine start <machine-name>   # 1) container runtime
cloudflared tunnel --config ~/.cloudflared/<tunnel-name>.yml run <tunnel-name>
./run-portal.sh                      # 2) portal (foreground; wrap in nohup/& for background)

Containers carry --restart unless-stopped; platform behavior after a WSL machine restart can still leave them stopped. The portal auto-starts the authorized user's container on launch and re-queues any instance left provisioning at boot. run-portal.sh re-applies the tenant egress firewall on every start (idempotent); if you restart the Podman machine while the portal is already running, run firewall/apply.sh manually.

Security model

  • Session cookie: HttpOnly, SameSite=Lax, Secure when COOKIE_DOMAIN is set; bearer tokens are SHA-256-digested in SQLite with server-enforced 7-day absolute and 24-hour idle expiry.
  • Passwords: bcrypt. OTP codes: salted SHA-256, 10-minute expiry, attempt-capped, constant-time compare; login/OTP/invite flows use persistent per-IP and per-account throttles.
  • Every subdomain request is authenticated (session) and authorized (owner or admin) before reaching a container; portal cookies and gateway identity headers are stripped before HTTP/WebSocket forwarding.
  • Portal mutations require the exact portal Origin and a per-session CSRF token; tenant sibling subdomains are treated as untrusted.
  • Instances are isolated: private home + workspace volumes, CPU/memory caps, dsh's own sandbox.
  • Tenant egress firewall: containers cannot reach RFC1918 private ranges (your LAN, 10/8, 172.16/12); only public internet egress is allowed. Enforced by nftables on the Podman machine (firewall/tenant-egress.nft), applied automatically by run-portal.sh.
  • The portal is the sole auth layer (no Cloudflare Access).

Files

deepseek-portal/
  portal/            Fastify app + static dashboard
    src/             backend modules (auth, db, otp, mailer, proxy, orchestrator, email-change)
    test/            transactional regression suite (npm test)
  image/             Dockerfile + start.sh + dsh-security.patch (dsh image)
  firewall/          tenant egress firewall (nftables) + apply.sh
  dsh/               fresh upstream clone (build context, gitignored)
  build-image.sh
  run-portal.sh      applies the egress firewall, then starts the portal
  .env.example
  README.md
  SECURITY_AUDIT.md  full audit, findings, and remediation status
  SECURITY.md        vulnerability disclosure policy