English | [中文](README.md) # dsh-relay **Map a home-run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) instance to the public internet** — get the exact same dsh experience as sitting at your home machine from any device, anywhere: live session sync, streaming output, tool cards, approval prompts, and workspace browsing. ``` Phone / remote PC ──HTTPS──→ Cloud relay (dsh-relay-cloud, one VPS) │ outbound trunk (dialed by the home plugin) ▼ Home dsh (stays bound to 127.0.0.1, zero exposed ports) └─ dsh-relay-host plugin: an "invisible browser" inside the dsh process, replaying every request from loopback ``` ## How it works (Wire-Trunk architecture) - **The home side is an out-of-tree plugin** (~200 lines, no build step): acting as an invisible browser inside the dsh process, it opens protocol-identical connections (POST `/api/*` + two downlink WebSockets) against the local `127.0.0.1:3080` and replays them verbatim over one outbound trunk to the cloud. - **The cloud is a pure forwarding plane**: remote browsers receive byte-identical stock traffic; static assets are served from the home instance and cached in the cloud — the frontend always matches the host version, so there is no protocol drift. - **Zero modifications to the dsh repository**: the plugin lives in the `~/.dsh/profiles/web/` user patch layer; `git pull`, auto-updates, and uninstall never interfere with each other. - **Security**: dsh itself ships no authentication and pins privileged methods to loopback — this design makes remote requests enter with a loopback identity while adding authentication (pairing code → long-lived cookie) at the cloud edge. The home side opens no inbound ports (NAT-friendly by construction). --- ## Full Deployment Guide Three roles: **cloud** (a public server), **home** (the machine running dsh), and **remote** (phone / any browser). About 15 minutes end to end. ### Prerequisites | Role | Requirement | |---|---| | Cloud | Linux server with a public IP (1 vCPU / 1 GB is enough), SSH access, one open TCP port (8443 below) | | Home | A working `dsh web` (default 127.0.0.1:3080), Node ≥ 22 | | Remote | Any modern browser | ### Step 1: Generate credentials (on the home machine) ```sh # Trunk token (home ↔ cloud identity, 32 hex chars) node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" # → note as # Pairing code (entered once per browser, 8 hex chars) node -e "console.log(require('crypto').randomBytes(4).toString('hex'))" # → note as ``` > ⚠️ These two values are the keys to the whole system. Store them safely; never commit them. ### Step 2: Deploy the cloud SSH into your server: ```sh ssh root@ ``` **2.1 Install Node 22** (skip if `node -v` shows ≥ 22): ```sh ARCH=$(uname -m); case "$ARCH" in x86_64) NARCH=x64;; aarch64) NARCH=arm64;; esac curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-$NARCH.tar.xz -o /tmp/node.tar.xz tar -xJf /tmp/node.tar.xz -C /opt ln -sfn /opt/node-v22.14.0-linux-$NARCH /opt/node ln -sf /opt/node/bin/node /usr/local/bin/node ln -sf /opt/node/bin/npm /usr/local/bin/npm node -v # v22.14.0 ``` **2.2 Fetch the code and install dependencies**: ```sh git clone https://github.com/SunNull/dsh-relay.git /opt/dsh-relay cd /opt/dsh-relay/cloud npm install ``` **2.3 Create a systemd service** (replace ``/``): ```sh cat > /etc/systemd/system/dsh-relay.service < Environment=DSH_RELAY_PAIRING_CODE= WorkingDirectory=/opt/dsh-relay/cloud ExecStart=/usr/local/bin/node server.mjs Restart=always RestartSec=3 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now dsh-relay systemctl status dsh-relay # active (running) curl -s http://127.0.0.1:8443/healthz # {"ok":true,...,"trunkReady":false} ``` > `trunkReady:false` is expected — the home side is not connected yet. **2.4 Open the port** (TCP 8443 inbound, source 0.0.0.0/0): - Cloud provider console → instance → security group / firewall → add rule TCP 8443 - If ufw is active on the server: `ufw allow 8443/tcp` Verify from the **home** machine: open `http://:8443/healthz` — you should see JSON. ### Step 3: Install the home plugin On the home machine, pick one of two ways. **For the admin panel UI use Option A**; Option B is functionally equivalent but the sidebar entry will not appear (see its note). **Option A: standard bundle install** (the repo declares `dsh.bundle`; one command mounts it; the panel UI relies on this option's `dsh.client` discovery): ```sh dsh plugin --profile web add "github:SunNull/dsh-relay" ``` Then append the trunk credentials to `~/.dsh/profiles/web/cordis.patch.yml` (the bundle layer carries no credentials; the user layer overrides it): ```yaml - id: dsh-relay-host config: relayUrl: ws://:8443/trunk token: ``` **Option B: installer one-liner** (clones the repo and writes the config for you): ```sh git clone https://github.com/SunNull/dsh-relay.git cd dsh-relay node install.mjs --relay-url ws://:8443/trunk --token ``` The installer: 1. Copies the plugin's three files (`index`/`admin`/`client`) flat into `~/.dsh/profiles/web/plugins/` (`index.mjs` imports `./admin.mjs` by relative path, so all three must sit side by side) 2. Appends a managed block to `~/.dsh/profiles/web/cordis.patch.yml` (dsh hot-reloads this patch layer) > Option B note: the relay and the admin route work fully, but the **⚙ Relay admin sidebar entry will not appear** — the client panel rides the bundle's `dsh.client` discovery (Option A). Switch to Option A if you want the panel UI. Both ways need **one `dsh web` restart** (module code needs process loading; config-only changes later need no restart). Uninstall: Option A `dsh plugin --profile web remove dsh-relay-host`, Option B `node install.mjs --uninstall`: ```sh # restart your dsh web, e.g. pnpm dsh web ``` **Verify the trunk** — on the server: ```sh curl -s http://127.0.0.1:8443/healthz # "trunkReady":true means connected; false → check plugin config and token ``` ### Step 4: Remote access Open this on a phone (or any device): ``` http://:8443 ``` 1. The pairing page appears the first time → enter `` → "配对" 2. You land in the full dsh UI, identical to your home machine (sessions, model, workspaces all synced) 3. "Add to Home Screen" gives an app-like experience Repeat on more devices as needed (default cap: 10, see env vars). ### Step 5 (highly recommended): remote workspace selection By default dsh opens a **native OS directory dialog** on the host desktop, invisible to remote browsers. Append this patch so everyone uses the **in-page directory browser**: Edit `~/.dsh/profiles/web/cordis.patch.yml` and append: ```yaml - id: directory-picker disabled: true - insert: - id: directory-picker-browse-host name: '@deepseek-ai/dsh-host-directory-picker-browse' - id: directory-picker-browse-ui name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' ``` Saves hot-apply (this is dsh's officially documented composition for remote-serving deployments). "Add workspace" now opens a web directory browser any device can use. ### Step 6 (optional but recommended): HTTPS Plain HTTP risks interception. With a domain name you get automatic HTTPS: ```sh # Install Caddy (Debian/Ubuntu official repo) apt install -y debian-keyring debian-archive-keyring apt-transport-https curl curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list apt update && apt install caddy ``` Point a DNS A record at the server, then: ```sh cp /opt/dsh-relay/cloud/Caddyfile.template /etc/caddy/Caddyfile # edit the file: replace dsh.example.com with your domain nano /etc/caddy/Caddyfile systemctl reload caddy ``` Use `https://` from now on; repoint the home side (hot-applies, no dsh restart): ```sh cd dsh-relay node install.mjs --relay-url wss:///trunk --token ``` --- ## Admin panel The **⚙ Relay admin** entry in the dsh web sidebar opens the built-in admin panel — it works both locally and from remote paired browsers, so you can manage everything from your phone on the couch. Four areas: 1. **Status card**: trunk state, bridged downlinks, cache entries, cloud uptime 2. **Device table**: each paired device's label / paired-at / last-seen, per-device revoke (instantly invalidates its cookie and tears down live sockets) or revoke-all in one click 3. **Pairing-code table**: multiple labeled pairing codes — create, click-to-reveal, enable/disable, regenerate, delete 4. **Audit**: tail of cloud-side events (pairing outcomes, revocations, code management) The security model in one line: **management follows dsh-web reachability** — whoever can open your dsh web can manage the relay; if you lose a phone, revoke it from the home machine or any other paired device. > Upgrade requirement: the panel needs **both the cloud and the home plugin** on 0.2.0+ (at home, re-run `dsh plugin --profile web add "github:SunNull/dsh-relay"` or `node install.mjs`, then restart dsh web). --- ## Operations | Task | How | |---|---| | Cloud status & audit | Visit `http://:8443/__relay` (JSON) | | Health check | `curl http://:8443/healthz` (no auth) | | Cloud logs | `journalctl -u dsh-relay -f` | | Revoke one / all paired devices | Use the "Relay admin" panel in the dsh web sidebar (no SSH needed; see [Admin panel](#admin-panel)) | | Update cloud code | `cd /opt/dsh-relay && git pull && systemctl restart dsh-relay` (per-release changes: [CHANGELOG](CHANGELOG.md)) | | Uninstall home plugin | `node install.mjs --uninstall` (delete the repo dir for a full cleanup) | | After a dsh restart | Nothing to do — the plugin reconnects automatically with backoff | ## Cloud environment variables | Variable | Default | Meaning | |---|---|---| | `DSH_RELAY_PORT` | 3081 | Listen port | | `DSH_RELAY_BIND` | 127.0.0.1 | Bind address; 0.0.0.0 for public deployments | | `DSH_RELAY_TOKEN` | dev-token | Trunk token (must match plugin config.token) | | `DSH_RELAY_PAIRING_CODE` | random, printed | Browser pairing code; set explicitly in production | | `DSH_RELAY_STATE` | ./relay-state.json | Device tokens + audit tail persistence | | `DSH_RELAY_MAX_TOKENS` | 10 | Max paired devices | | `DSH_RELAY_MAX_WS` | 16 | Max concurrent browser WebSockets | | `DSH_RELAY_BLOCK_PRIVILEGED` | 0 | `1` = block settings/credentials methods at the cloud | | `DSH_RELAY_HEARTBEAT_MS` | 20000 | Keepalive cycle for the trunk and every browser downlink (ms). A browser that misses a full cycle of pongs is treated as dead and dropped (the stock client auto-reconnects and replays the baseline); the ping traffic also keeps NAT mappings from expiring on quiet links | ## Acceptance probes (probe/) Run these after deployment to verify the full chain: ```sh # 1) Trunk essentials (static/RPC/WS bridge) — edit BASE at the top first: node probe/e2e-probe.mjs # 2) Browser end-to-end (pairing → UI → WS); needs pip install playwright && playwright install chromium: DSH_RELAY_BASE=http://:8443 DSH_RELAY_PAIRING_CODE= python probe/browser-acceptance-m1.py ``` ## Security notes (read this) - **The pairing code is the key**: a paired device ≈ sitting at your home machine (chat, commands, files). Use a strong code + HTTPS on public deployments. - **Privileged surface**: by default remote clients can reach dsh's settings/credentials plane. Set `DSH_RELAY_BLOCK_PRIVILEGED=1` for a more conservative posture (chat/files unaffected). - The cloud persists no business data: `relay-state.json` holds only device tokens and audit counters — **never session content**. - Token rotation: change `` in the cloud systemd unit and re-run `install.mjs --token` at home. ## Troubleshooting | Symptom | Cause & fix | |---|---| | Remote shows "home offline" page | Home dsh not running / plugin not connected. Check `trunkReady` via healthz; verify token & network | | Pairing says device limit reached | Test pairings filled the 10 slots. Use the panel's revoke-all, then re-pair | | **Forgot the pairing code** | Click-to-reveal it in the "Relay admin" panel; pre-0.2.0 deployments can read it from the cloud's `relay-state.json` (or the systemd unit / boot log) | | **Rotate the pairing code** | Use the panel's **regenerate** on the target code (or delete + recreate). Note: editing `DSH_RELAY_PAIRING_CODE` in systemd and restarting does **not** rotate existing codes — once the state file exists the env var only seeds a missing table and old codes keep working; revoke a code via the panel's regenerate/disable/delete instead | | First load is slow (10-20 s) | Normal: cold cache fetches static assets one by one; subsequent loads hit the cache | | Remote "add workspace" does nothing | Step 5 patch missing (the native dialog opened on the home desktop) | | Blank page / infinite spinner | Hard-refresh / clear cache first; then run the probes to localize (static / RPC / WS) | ## Known limitations - HTTP responses are buffered whole (except the SSE download path); very large exports wait for completion - The dsh web wire has no version negotiation (client/host ship together) — assets served from the home instance stay version-locked by construction; re-run the probes after major dsh upgrades - Home plugin module updates require a dsh web restart (patch config hot-applies; module code does not hot-swap) ## Verified against DeepSeek Harness `0.1.0-rc.5` (2026-08). dsh is in developer preview; treat the probes as the acceptance contract when the wire changes. ## License [MIT](LICENSE)