dsh-auth-gate Deployment and Acceptance Checklist

September 12, 2026 · View on GitHub

This document describes how to deploy dsh-auth-gate (password mode, M3) to a public dsh web instance and complete deployment acceptance. Applies to: an instance already running dsh web (dsh --profile web) that needs an authentication gate.

Design basis: docs/specs/dsh-auth-plan.md §7/§8 (absence of an upstream PR channel, defense in depth); specifications: docs/implemented/impl-m2.md (token mode), docs/implemented/impl-m3.md (password mode). Choose one of the two modes: the text below is written for password mode (recommended, M3); token mode only needs to skip the "create users" step and configure tokenRef.


0. Prerequisites

  • TLS front termination (Nginx/Caddy reverse proxy or LB): cookieSecure: true depends on https, otherwise the browser does not save the session cookie (curl/scripts are unaffected). An http-only environment can only use cookieSecure: false (testing only).
  • --trusted-host and authentication are orthogonal: --trusted-host is merely a DNS-rebinding guard, not authentication; both need to be configured. Public instance: --trusted-host <domain> points to your domain.
  • Run dsh under a low-privilege OS user (no sudo, no other project files) — plan §8, defense-in-depth item 1.
  • The server has Node ≥ 22.19 (consistent with deployment) and pnpm (dsh plugin forwards to pnpm). Verified: npm's default global prefix is /usr (cannot install without root privileges); use npm i -g pnpm --prefix ~/.npm-global and export PATH="$HOME/.npm-global/bin:$PATH" (dsh itself is also installed in this prefix, see docs/handoff/handoff-m2.md §3.2).

1. Install dsh-auth-gate (one-time)

The package is published to npm (dsh-auth-gate); one command installs it into the target profile:

# Server ($DSH_HOME points at the target instance, e.g. ~/.dsh or an isolated directory):
export PATH="$HOME/.npm-global/bin:$PATH"
dsh plugin --profile web add dsh-auth-gate   # forwards to pnpm, resolved from public npm (verified)
  • After installation, the dependencies of $DSH_HOME/profiles/web/package.json include dsh-auth-gate; dependencies (yaml, @deepseek-ai/*) are resolved automatically from public npm.
  • The CLI binary is not added to PATHdsh plugin add only runs pnpm in the profile directory, so dsh-auth resolves exclusively from $DSH_HOME/profiles/web/node_modules/.bin. §2 shows the correct invocation.
  • Upgrade: re-run the same command (pnpm pulls the new version).
  • Uninstall: dsh plugin --profile web remove dsh-auth-gate (since 0.4.1 the bundle declaration makes dsh plugin add register the mount in dsh.profile.bundles, so remove also drops it; a leftover - id: dsh-auth-gate config override in $DSH_HOME/cordis.patch.yml becomes a no-op with a boot warning and can be deleted).

2. Configuration

  1. Create the administrator (users.yaml is auto-created at $DSH_HOME/auth/users.yaml, 0600):
    # The CLI is not on PATH (see §1): call it through the profile.
    printf '%s\n' '<strong password>' | \
      pnpm --dir "$DSH_HOME/profiles/web" exec dsh-auth user add admin --password-stdin
    pnpm --dir "$DSH_HOME/profiles/web" exec dsh-auth user list   # confirm
    
    Alternative (no pnpm needed at runtime): node "$DSH_HOME/profiles/web/node_modules/dsh-auth-gate/lib/cli.js" .... Multiple administrators: repeat user add; disable: dsh-auth user disable <name>.
  2. Config override: copy the repo's deploy/cordis.patch.yml to $DSH_HOME/cordis.patch.yml — since 0.4.1 the template is a pure config override (no insert; the mount itself is registered by dsh plugin add via the dsh.bundle manifest). Adjust as needed (cookieSecure must match the TLS environment; only set usersFile for a non-default path).
  3. Confirm no other line occupies the dsh-auth-gate id (the patch stack overrides by id).

3. Startup and Health Check

cd "$DSH_HOME" && DSH_HOME="$DSH_HOME" setsid dsh --profile web --port 3081 \
  > ~/dsh.log 2>&1 < /dev/null &                         # wait ~25s
tail -f ~/dsh.log                                        # expected: no errors

(Verified: in an SSH session, nohup ... & may hang ssh for 2 minutes because a child process holds the fd — a hang does not mean failure; setsid returns immediately; after startup, open another connection to check pgrep and the port. To stop: pkill -f "[d]sh --profile web" — the bracket trick prevents self-kill; kill and startup are in two separate connections.)

Startup self-check (M1): if the four categories of entry points are not fully covered, it fails loud (process startup fails and reports guard self-check failed) — successful startup means the guard is in place. The log should show session domain opened: dsh_auth_sessions; no user store unavailable / users file not found (that warn is normal before first login — a missing file = empty user set, fail-closed).

4. Acceptance Checklist (run each item after deployment)

Run on the server itself (or via SSH tunnel). jar is a curl cookie jar; <TOKEN> is the dsh_auth value from the set-cookie in the login response (43 characters).

# A. Login page and the guard
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3081/auth/login          # 200
curl -s http://127.0.0.1:3081/auth/login | grep -o 'name="username"'                # hit

# B. Unauthenticated rejection (navigation 302 / API 401)
curl -s -o /dev/null -w "%{http_code}\n" -H "Accept: text/html" http://127.0.0.1:3081/__dsh_api  # 302
curl -s -o /dev/null -w "%{http_code}\n" -H "Accept: application/json" http://127.0.0.1:3081/__dsh_api  # 401

# C. Login (wrong → 401; correct → 302 + set-cookie)
curl -s -o /dev/null -w "%{http_code}\n" -d "username=admin&password=wrong" http://127.0.0.1:3081/auth/login  # 401
curl -s -i -d "username=admin&password=<password>" -c jar http://127.0.0.1:3081/auth/login | head -3  # 302 + set-cookie

# D. Session cookie and Bearer session token
curl -s -o /dev/null -w "%{http_code}\n" -b jar http://127.0.0.1:3081/__dsh_api      # 200
curl -s -b jar http://127.0.0.1:3081/auth/status                                     # {"authenticated":true}
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer <TOKEN>" http://127.0.0.1:3081/__dsh_api  # 200

# E. Route discipline (/auth falls through without hitting the SPA fallback; method discipline)
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3081/auth/whatever         # 404
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://127.0.0.1:3081/auth/login  # 405

# F. WS upgrade channel (first-line status is enough; timing out via --max-time is normal)
curl --http1.1 -s -i --max-time 2 -b jar -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" -H "Sec-WebSocket-Version: 13" \
  http://127.0.0.1:3081/api/events.host | head -1                                    # 101
# no-cookie variant → first line 401

# G. Logout and revocation
curl -s -i -X POST "http://127.0.0.1:3081/auth/logout?next=/" -b jar | head -3        # 302 + Max-Age=0
curl -s -o /dev/null -w "%{http_code}\n" -b jar http://127.0.0.1:3081/__dsh_api      # 401

# H. Rate limiting (run last — locks for 30s onward)
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" -d "username=admin&password=wrong" \
  http://127.0.0.1:3081/auth/login; done       # first ~N times 401, after lock 429 (IP bucket accumulates prior failures)
curl -s -i -d "username=admin&password=<password>" http://127.0.0.1:3081/auth/login | head -3  # 429 + retry-after

# I. Browser path (optional, requires an https environment): incognito window → visit → 302 to /auth/login →
#    login → enter the instance; a prominent "Sign out / 退出登录" button sits inside the Settings panel
#    (Settings → General, bottom; client half, 0.6.5+), or visit /auth/logout?next=/ via URL to log out.

All green = deployment acceptance passes. Every failure path must fail (401/403 semantics, never swallow errors) — any "silent pass-through" (unauthenticated returning 200/101) is a deployment error. Verified note: cookieSecure: true only affects browsers (curl's cookie jar does not check Secure, so the acceptance sequence runs the same); the lock count in group H accumulates prior failures from earlier steps (e.g. the wrong attempt in C) — look for the 429 + retry-after to appear.

5. Upgrade and Regression (mandatory after every dsh upgrade)

The guard wrapper depends on webServer's non-contractual internal structures (plan §7) — after each dsh upgrade:

  1. Start up (§3) — a fail-loud self-check means failure;
  2. Run acceptance groups B/D/F of the checklist (guard + session + WS);
  3. Re-run the unauthenticated entry-coverage probe (read-only, no credentials): node scripts/check-live-entries.mjs (defaults to http://127.0.0.1:3080). It discovers every registered exact / prefix / upgrade / fallback entry in the loaded profile and exits non-zero if any entry answers 2xx without a session. Latest verified run: entry-coverage-0.1.5-rc.2.md — dsh 0.1.5-rc.2, 56 entries from 8 packages, 61/61 probes rejected (401, or 302 to /auth/login for HTML).
  4. Check boot.log for any new error/warn;
  5. For dsh upgrades to 0.1.2-alpha and later: dsh web adds a page-level launch-token gate (a fresh browser needs /?token= once). dsh-auth-gate bridges it after a successful login via a relative /?token=… redirect (see docs/implemented/impl-launch-token-bridge.md). After the upgrade, run acceptance item A with a fresh browser (no dsh cookie): signing in should land directly on the instance — no 401 token-gate wall. In boot.log, launch-token bridge inactive is expected only when dsh has no authenticatedUrl (older dsh); launch-token bridge unavailable warrants connection-service troubleshooting.

5.1 Upgrading dsh-auth-gate (0.11.0 → 0.11.1, TOTP hardening)

Real-world bumps (verified on web-test, 2026-08-30):

  1. Fresh releases are blocked by pnpm's minimumReleaseAge — a plain pnpm up dsh-auth-gate may silently do nothing. Pin the version explicitly and use the pnpm major that matches the profile's node_modules (the profile declares packageManager: pnpm@11.22.0; the system pnpm 9.x fails on store mismatch):
    corepack pnpm@11.22.0 --dir "$DSH_HOME/profiles/<profile>" up dsh-auth-gate@0.11.1
    # verify: grep '"version"' "$DSH_HOME/profiles/<profile>/node_modules/dsh-auth-gate/package.json"
    
  2. Restarting invalidates in-flight TOTP challenges (HMAC-signed challenge cookie, process-keyed, ADR D10): users on the code page must re-enter their password (window ≤ 5 minutes). The legacy plaintext cookie format also stops working after upgrade — treated as "no challenge", users simply see the password page.
  3. After upgrade, run at least: dsh-auth user list sanity, then the TOTP acceptance round — password stage → challenge cookie (3-part signed value) → code page → correct code → session; wrong code → 401 with the challenge-page error slot; /auth/status with the session cookie → authenticated: true.

6. Troubleshooting

SymptomCauseHandling
Startup failure guard self-check failedWrapper does not cover all entry points (dsh version changed)Upgrade dsh-auth-gate or report (do not bypass the self-check)
Login always 401Wrong password / user disabled / users.yaml missing (empty user set)dsh-auth user list; confirm $DSH_HOME matches the instance
Login 503 user store unavailableusers.yaml syntax/schema error, permissions too loose (not 600)chmod 600; dsh-auth user list to reproduce the error message
Login 429Rate-limit lock (in-memory state, cleared on restart)Wait for retry-after or restart the instance
Rejected after browser logincookieSecure: true but no httpsAdd TLS front termination, or temporarily false (testing only)
API still 401 after authenticationReverse proxy does not forward cookie/AuthorizationCheck the reverse proxy's header pass-through config

7. Security Notes (plan §8 concrete checklist)

  • Both $DSH_HOME/.credentials.yaml and auth/users.yaml are chmod 600 (dsh-auth CLI auto-sets 600).
  • Treat session logs as confidential material (protect backups/sharing equally).
  • Fold upgrade regression (§5) into the ops process; add auth-line health checks (boot.log + acceptance B/D/F) to monitoring.
  • Password hashes are scrypt (docs/implemented/impl-m3.md P1); the file has zero plaintext.
  • dsh-auth user disable <name> blocks new logins and revokes that user's issued sessions (the plugin sweeps users.yaml every revokeSweepMs, default 5000 ms; set 0 to keep the old M3 behavior of blocking new logins only).
  • Rate limiting is in-memory and cleared on restart; in a reverse-proxy deployment, rate limiting aggregates by egress IP (do not trust X-Forwarded-For).

8. Public Deployment Variant (effective 2026-08-15 on dsh.hi-ruofei.com): Semi-Shell

§1–§7 of this document are the "plugin form" (the guard lives inside the dsh process). After production validation on 2026-08-15, the public instance switches to the semi-shell variant; the long-term direction is docs/specs/dsh-auth-plan.md §9 M5 (independent reverse-proxy shell).

8.1 Why a Shell Is Needed: The Browser-Trust Fence and Authentication Are Orthogonal

dsh-client-connection in dsh 0.1.0-rc.6 pins privileged methods such as settings.*/credentials.*/llm.discoverModels to loopback only (PRIVILEGED_METHODS, --trusted-host cannot loosen this). Under a public reverse proxy, the settings page's settings.describe/credentials.describe always return 403 ("transport failure"), unrelated to dsh-auth-gate — removing the guard and running bare still returns 403 (verified 2026-08-15).

Verified header matrix (cookie access to /api/settings.describe after login):

Upstream HostOriginResult
dsh.hi-ruofei.com (passed through unchanged)any403
127.0.0.1:3080 (rewritten)matches loopback200
127.0.0.1:3080 (rewritten)stripped200
127.0.0.1:3080 (rewritten)does not match403

8.2 Semi-Shell Topology (current production)

public dsh.hi-ruofei.com (Caddy, TLS)
  └─ reverse_proxy 127.0.0.1:3080 {
         header_up Host 127.0.0.1:3080   # rewrite Host → dsh treats it as loopback
         header_up -Origin                # strip Origin → passes the fence's Origin match
     }
       └─ dsh web (with the dsh-auth-gate guard; authentication logic unchanged)
  • Effect: all 13 settings-page APIs return 200 with zero console errors; login/rate limiting/revocation/Bearer all preserved; WS returns 101 normally.
  • Cost: dsh's browser-trust fence is bypassed (Host always loopback, Origin always absent); compensated by the guard — the session cookie's SameSite=Lax → cross-site/rebinding requests get no cookie → 401. Defense in depth changes from "fence + guard" to "guard + SameSite".
  • Rollback: /etc/caddy/Caddyfile.bak.shell (pre-shell) and $DSH_HOME/cordis.patch.yml.bak (guard-disabled state) are archived; after restoring, restart dsh-web and reload caddy to return to the plugin form.

8.3 Ops Notes (semi-shell specific)

  • Run the upgrade regression (§5) as usual; additionally smoke-test the settings page: after login, click "Settings" and confirm there is no transport failure and no 403 console errors.
  • --trusted-host dsh.hi-ruofei.com is already redundant after the rewrite (Host always loopback), but keeping it is harmless.
  • Sessions survive a dsh-web restart: they are persisted per instance in $DSH_HOME/storages/dsh_auth_sessions.json (mode 0600; keyed by the sha256 of the session token, no plaintext token on disk) and stay valid until they expire (sessionTtl, default 7 days) or are revoked (POST /auth/logout deletes the row on disk). What a restart does clear is process-level state: in-flight TOTP challenges (§5.1), the login rate limiter (§6), and the TOTP replay guard. A signed-in browser therefore stays signed in; a user sitting on the code page must re-enter the password.
  • Bare-running lesson: do not run bare in public without the shell and without the guard — agents have workspace write access and $DSH_HOME/.credentials.yaml contains model API keys, so anyone could freely invoke them.

9. Authenticated Local Proxy (optional extension, as of 2026-08-26)

The semi-shell (§8) fixed the server-side /api fence; dsh's client still has a "page origin must be loopback" check (isLoopback in dsh-client-connection only accepts localhost / [::1] / 127/8): on a domain page the settings mirror runs in memory mode and the settings page reports "settings are unavailable in this browser" (client-side, unrelated to authentication). This extension provides a loopback page entry on the user's machine, composing with the semi-shell and the guard to deliver "remote config editing with real authentication throughout", without touching dsh sources.

9.1 Topology and Authentication

User browser (http://127.0.0.1:8443  -- page origin loopback; client-side gate passes)
   └─ dsh-auth-proxy (user machine, strictly bound to 127.0.0.1, stateless pass-through)
        └─ https://dsh.hi-ruofei.com (SNI/Host = domain)
             └─ Caddy (§8.2 header rewrite: Host/Origin -> 127.0.0.1:3080)
                  └─ dsh web + dsh-auth-gate (authentication unchanged)
  • Authentication reuses auth-gate: the login page and its 302/Set-Cookie pass through untouched (the cookie is owned by 127.0.0.1:8443); --strip-secure-cookie (default on) removes the Secure attribute over plain-text loopback HTTP (one hop only; Chrome/Firefox would keep it anyway; Safari fallback). HttpOnly/SameSite=Lax/Path=/ are preserved.
  • The proxy stores no sessions or credentials (stateless; restarting it drops only its own connections and leaves dsh-web's persisted sessions untouched).
  • WebSocket upgrades (/api/events.mux, /api/events.host) are tunneled through the proxy too.

9.2 Usage

node lib/proxy-cli.js --listen 127.0.0.1:8443 --target https://dsh.hi-ruofei.com --mark-proxy
# Open http://127.0.0.1:8443 in the browser -> auth-gate login -> edit the settings pages
FlagDefaultPurpose
--listen127.0.0.1:8443Must be loopback (startup refuses anything else; no LAN trampoline)
--targethttps://dsh.hi-ruofei.comUpstream; requires https with TLS verification by default
--strip-secure-cookieon (--no-… disables)Remove Secure over plain-text loopback HTTP
--mark-proxyoffAdd X-Dsh-Proxy: 1 to every request (enables the §9.3 deny-list)
--local-token-env <VAR>noneEvery request must carry Authorization: Bearer <env value> (fail-closed: startup errors if the variable is unset)
--unsafe-plain-targetoffAllow http:// upstreams (local verification only)

9.3 Security Boundary: the X-Dsh-Proxy Deny-List (Phase 2.1)

Through the proxy the /api fence also treats host.pickDirectory, host.openPath, settings.openDocument and llm.discoverModels as loopback, so a remote authenticated user could trigger host-native capabilities (dialogs, opening host paths, SSRF-style probes). Defense: run the proxy with --mark-proxy; the auth-gate guard answers 403 forbidden (same shape as the /api fence) for marked requests hitting those methods, after the gate allowed them.

  • Unmarked traffic behaves exactly as if the proxy were not deployed; the operator opts in.
  • HTTP routes only; the WebSocket event channels are not on the deny list and are unaffected.
  • The marker header is spoofable, but spoofing only denies the spoofing caller (the refusal is self-inflicted); no amplification surface.

9.4 systemd Unit

Template: deploy/systemd/dsh-auth-proxy.service.example

sudo cp deploy/systemd/dsh-auth-proxy.service.example /etc/systemd/system/dsh-auth-proxy.service
# Edit ExecStart (binary path and --target) for the actual deployment
sudo systemctl daemon-reload && sudo systemctl enable --now dsh-auth-proxy

9.5 Acceptance Checklist (run each item after deployment)

  1. The proxy prints listening on http://127.0.0.1:8443 -> …; a non-loopback --listen exits with an error;
  2. curl GET / (with Accept: text/html) -> 302 /auth/login;
  3. POST /auth/login -> 302 + set-cookie (without Secure);
  4. With the cookie, POST /api/settings.describe (RPC envelope {"type":"client-request","rpcId":"x","method":"…","payload":{}}, Content-Type: application/json) -> 200 {"ok":true,…};
  5. Browser: after login, "Settings -> Models" shows no "settings are unavailable" and the provider rows are editable;
  6. With --mark-proxy: a marked settings.describe still returns 200; host.openPath returns 403;
  7. Regression: the models page opened directly on https://dsh.hi-ruofei.com still shows the original error (expected — no proxy).