vinokok/dsh-external-access-guide

dsh-external-access-guide

远程 VPS 上部署 DSH 并通过 HTTPS 安全开放外网访问 | Deploy DSH on a remote VPS and expose it securely over HTTPS

Uncategorized

Remote VPS DSH external HTTPS deployment

Agent Skill

Read the automation skill: SKILL.en.md

中文 Skill / Chinese default

This skill lets a local Agent connect to a remote VPS over OpenSSH and automate DSH installation, HTTPS, password gateway, settings compatibility, verification, upgrades, and rollback.

This guide documents how to expose a remote DeepSeek Harness (DSH) Web installation on an Ubuntu VPS through a public HTTPS domain, Nginx/reverse proxy, and a password-only login gateway. It is intended for an administrator connecting to the VPS over SSH; it does not modify the DSH desktop client running on the administrator's computer.

The reference deployment used a VPS at <VPS_IP>, DSH under /opt/dsh-headless-test, Nginx, and a domain such as <dsh.example.com>. Substitute your own values.

Security: this document is intentionally sanitized. Replace every value in angle brackets. Never commit passwords, API keys, SSH private keys, TLS private keys, session secrets, password hashes, cookies, or .env files.

Architecture

Browser
  |
  | HTTPS + password-only session cookie
  v
Nginx :443
  |
  v
Independent login gateway 127.0.0.1:3090
  |
  | authenticated reverse proxy
  v
DSH Web 127.0.0.1:3081

The DSH process and gateway bind only to loopback. The only public entry point is Nginx on HTTPS.

Prerequisites

  • Ubuntu VPS with Node.js and systemd
  • A DNS name such as <dsh.example.com> pointing to the VPS
  • Nginx or another Nginx installation
  • DSH installed in a directory outside the gateway, for example /opt/dsh-headless-test
  • A dedicated service account is preferable to root; use root only where the existing deployment requires it

1. Connect from the local Agent to the remote VPS

The deployment starts on the local Agent. Use the system OpenSSH client and one-shot remote commands; the connection and deployment logic is implemented by this standalone skill.

Verify the SSH host key before connecting:

ssh-keyscan -t ed25519 <VPS_HOST> > /tmp/<VPS_HOST>.known_hosts
ssh-keygen -lf /tmp/<VPS_HOST>.known_hosts
ssh -o UserKnownHostsFile=/tmp/<VPS_HOST>.known_hosts -o StrictHostKeyChecking=yes <VPS_USER>@<VPS_HOST>

The automation Agent must use an SSH private key from local secure storage. Never write private keys, passwords, or tokens into the skill, scripts, logs, or Git repository. Run a read-only preflight first:

ssh -o UserKnownHostsFile=/tmp/<VPS_HOST>.known_hosts -o StrictHostKeyChecking=yes <VPS_USER>@<VPS_HOST> \
  'uname -a; command -v systemctl; command -v node || true; command -v nginx || true'

Continue only after the host identity, OS, disk space, Node.js, systemd, and reverse-proxy state are understood.

2. Install DSH as an independent service

Create a private application directory and install DSH there. Keep the DSH package separate from all gateway files so upgrades do not overwrite the access layer.

Example service:

# /etc/systemd/system/dsh-web.service
[Unit]
Description=DeepSeek Harness Web UI
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=<dsh-user>
WorkingDirectory=/opt/dsh-headless-test
Environment=DSH_HOME=/opt/dsh-headless-test/home
ExecStart=/opt/dsh-headless-test/node_modules/.bin/dsh web --host 127.0.0.1 --port 3081 --no-open --trusted-host <dsh.example.com>
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Apply and verify:

systemctl daemon-reload
systemctl enable --now dsh-web.service
systemctl status dsh-web.service
ss -ltnp | grep 3081

The listener should be 127.0.0.1:3081, never 0.0.0.0:3081.

3. Configure DNS and issue HTTPS

Create an A record:

<dsh.example.com> -> <VPS_PUBLIC_IP>

For initial Let's Encrypt HTTP-01 validation, DNS must resolve directly to the VPS or Cloudflare must be configured so the challenge reaches Nginx. If Cloudflare proxying is temporarily disabled, re-enable it only after issuance and use Full (strict) with a valid origin certificate.

Use a temporary HTTP-only Nginx server for ACME validation:

server {
    listen 80;
    server_name <dsh.example.com>;
    root /www/wwwroot/<dsh.example.com>;
    location ^~ /.well-known/acme-challenge/ { try_files $uri =404; }
    location / { return 301 https://$host$request_uri; }
}

Issue the certificate with certbot webroot, then configure the certificate and key paths in the reverse proxy. Protect the private key with restrictive permissions.

certbot certonly --webroot -w /www/wwwroot/<dsh.example.com> -d <dsh.example.com>
install -d -m 750 /www/server/panel/vhost/cert/<dsh.example.com>
install -m 640 /etc/letsencrypt/live/<dsh.example.com>/fullchain.pem /www/server/panel/vhost/cert/<dsh.example.com>/fullchain.pem
install -m 640 /etc/letsencrypt/live/<dsh.example.com>/privkey.pem /www/server/panel/vhost/cert/<dsh.example.com>/privkey.pem

Create a renewal deploy hook that copies renewed files and reloads Nginx only after nginx -t succeeds.

4. Use Nginx for TLS and WebSocket forwarding

The HTTPS virtual host should forward to the gateway, not directly to DSH:

map $http_upgrade $dsh_connection_upgrade {
    default upgrade;
    '' close;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name <dsh.example.com>;

    ssl_certificate /www/server/panel/vhost/cert/<dsh.example.com>/fullchain.pem;
    ssl_certificate_key /www/server/panel/vhost/cert/<dsh.example.com>/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://127.0.0.1:3090;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $dsh_connection_upgrade;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;
    }
}

Validate before reload:

/www/server/nginx/sbin/nginx -t
/www/server/nginx/sbin/nginx -s reload

5. Deploy a password-only gateway

The gateway is an independent Node.js service. It should provide:

  • Generic login page with no protected-product name
  • Server-side password verification using PBKDF2, bcrypt, or Argon2
  • No plaintext password in HTML or JavaScript
  • Signed HttpOnly, Secure, SameSite=Strict session cookie
  • Expiration and logout support
  • Rate limiting and failure delay
  • HTTP reverse proxy and WebSocket upgrade forwarding
  • Loopback-only listener

Example systemd unit:

# /etc/systemd/system/dsh-login-gateway.service
[Unit]
Description=DSH password gateway
After=network-online.target dsh-web.service
Requires=dsh-web.service

[Service]
Type=simple
User=<gateway-user>
WorkingDirectory=/opt/dsh-login-gateway
Environment=NODE_ENV=production
Environment=GATEWAY_HOST=127.0.0.1
Environment=GATEWAY_PORT=3090
ExecStart=/usr/bin/node /opt/dsh-login-gateway/server.js
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/dsh-gateway

[Install]
WantedBy=multi-user.target

Store secrets separately:

install -d -m 750 /var/lib/dsh-gateway
chmod 600 /var/lib/dsh-gateway/password.hash /var/lib/dsh-gateway/session.secret

Never put secrets in the public repository.

6. DSH settings and remote HTTPS

DSH restricts settings, credential, and model-discovery operations to loopback same-origin requests. A password gateway is an authentication boundary, but the browser address remains a public domain. Two parts are needed:

  1. The gateway forwards authenticated requests internally with Host: 127.0.0.1:3081 and removes browser-origin trust markers before forwarding.
  2. The served DSH browser connection bundle needs a compatibility layer that marks this authenticated surface as a controlled Host.

Keep this compatibility layer outside node_modules, for example:

/opt/dsh-login-gateway/plugins/remote-host-compat.js

The gateway should patch only the exact @deepseek-ai/dsh-client-connection/client.js response, verify known source markers, and fail closed if the DSH bundle structure changes. Do not edit DSH package files directly. This keeps the change independent from DSH package upgrades.

After every DSH upgrade, verify:

curl -k -b <authenticated-cookie-file> \
  https://<dsh.example.com>/plugins/@deepseek-ai/dsh-client-connection/client.js \
  | grep 'isLoopback: true'

Then open the model settings page in a private window and confirm that the provider catalog loads and settings are writable.

7. Verification checklist

systemctl is-active dsh-web.service
systemctl is-active dsh-login-gateway.service
ss -ltnp | grep -E '127.0.0.1:(3081|3090)'
/www/server/nginx/sbin/nginx -t
curl -k -I https://<dsh.example.com>/

Functional checks:

  • Unauthenticated request returns only the generic login page
  • Wrong password returns 401
  • Rate limit returns 429 after repeated failures
  • Correct password sets an HttpOnly; Secure; SameSite=Strict cookie
  • Authenticated root page reaches DSH
  • Model/provider settings load
  • WebSocket connection remains usable
  • Direct access to ports 3081 and 3090 from the public network fails
  • Certificate renewal hook passes nginx -t before reload

8. Upgrade procedure

# Back up service and gateway configuration first
systemctl cat dsh-web.service > /var/backups/dsh-web.service.txt
systemctl cat dsh-login-gateway.service > /var/backups/dsh-login-gateway.service.txt
cp /www/server/panel/vhost/nginx/<dsh.example.com>.conf /var/backups/

# Upgrade DSH in its own installation directory
cd /opt/dsh-headless-test
pnpm update @deepseek-ai/dsh

# Restart and verify
systemctl restart dsh-web.service
systemctl restart dsh-login-gateway.service
systemctl is-active dsh-web.service dsh-login-gateway.service

The gateway source, secrets, systemd unit, Nginx site, and compatibility plugin live outside DSH's npm package and should survive upgrades.

9. Rollback

Keep a dated backup of:

  • Nginx virtual host
  • DSH systemd unit
  • Gateway systemd unit
  • Gateway source
  • Profile configuration

Rollback steps:

systemctl stop dsh-login-gateway.service
# Restore the dated files after reviewing their contents
/www/server/nginx/sbin/nginx -t
/www/server/nginx/sbin/nginx -s reload
systemctl start dsh-web.service

If the compatibility plugin rejects a new DSH bundle, leave it disabled until the marker logic is reviewed. Do not bypass the fail-closed behavior by blindly patching arbitrary JavaScript.

Troubleshooting

settings are unavailable in this browser

The browser bundle still considers the public hostname a non-loopback surface. Confirm the compatibility layer is active and that the browser loaded a fresh bundle. Use a private window or clear site data.

transport failure for /api/llm.providers: HTTP 403

Check that the gateway strips Origin, Referer, and Sec-Fetch-* before the internal request, sets the internal loopback Host, and preserves WebSocket forwarding. Check Nginx and gateway logs.

Browser Basic Auth popup appears

An old Nginx auth_basic directive is still active. Remove it from the HTTPS location and proxy to the gateway instead.

502 after changing the bundle

When rewriting a response, remove transfer-encoding: chunked before setting a new content-length. Preserve the JavaScript content type and disable stale caching for the transformed asset.

Security notes

  • Use a unique long password and rotate it after any suspected exposure.
  • Use a separate GitHub deploy credential with least privilege and short expiry.
  • Keep Cloudflare in Full (strict) mode when proxying is enabled.
  • Prefer a dedicated non-root Linux service account.
  • Do not expose DSH's loopback ports with firewall rules or port forwarding.
  • Review the compatibility transform after each DSH version change.