dsh-tailscale-gateway
Private Tailscale access for DeepSeek Harness Web: exact user allowlists, loopback-only gateway, and guarded Serve setup
- Stars
- 11
- Language
- JavaScript
- Created
- Aug 16, 2026
- Updated
- Aug 20, 2026
Introduction
dsh-tailscale-gateway
Private DSH Web access for the people you choose—not your whole network.
Give selected Tailnet users private browser access to a local DeepSeek Harness (DSH) Web UI—without exposing DSH on your LAN or the public Internet. This small, dependency-free DSH Web-profile bundle keeps DSH and the gateway on loopback, then uses Tailscale Serve as the only remote ingress.
Allowlisted Tailnet browser ─ HTTPS ─> Tailscale Serve
│
└─ identity-aware gateway ─> local DSH
127.0.0.1:3088 127.0.0.1:3080
What it gives you: an exact Tailscale-login allowlist in front of DSH, a loopback-only gateway, and conflict-safe private Serve setup. Installing alone does nothing. Guided setup writes an enabled profile entry only after your confirmation; the next DSH Web start activates it. It never opens a LAN listener or configures Funnel.
Why this gateway?
Tailscale delivers an authenticated connection. This bundle turns that connection into a deliberately narrow, DSH-aware access boundary:
| What you need | What this bundle does |
|---|---|
| Share DSH with only specific people | Allows exact Tailscale-User-Login identities, not every Tailnet member who can reach the node. |
| Keep DSH local | Pins both DSH and the gateway to 127.0.0.1; there is no LAN listener or public Funnel mode. |
| Use the normal DSH web app remotely | Guards and proxies the UI, HTTP API, and WebSocket event streams, then forwards to DSH as its fixed loopback origin. |
| Avoid route accidents | Setup infers the node owner and a safe available HTTPS port; ensure creates only its one absent route and refuses conflicts or Funnel. |
| Preserve existing services | It never resets Serve, overwrites another handler, or removes a route automatically. |
The result is straightforward: install, approve the generated plan, restart
DSH, then open the Tailscale URL. There is no separate tailscale serve
command in the normal flow.
Set up in two commands, then restart DSH
You need a working local DSH Web profile and a non-tagged Tailscale node with MagicDNS and HTTPS enabled. Node.js 20+ is normally supplied by DSH.
-
Install the inert bundle. This alone neither starts a listener nor changes Tailscale.
dsh plugin --profile web add -w github:TiantianFlow/dsh-tailscale-gateway-wis required because DSH's Web profile is a pnpm workspace root. -
Run guided setup and confirm the displayed plan. It infers the current node owner's Tailscale login as the first allowlisted user; you can replace it at the prompt.
dsh plugin --profile web exec dsh-tailscale-gateway-setupYour confirmation writes an enabled profile entry with guarded
tailscaleServe.mode: ensure. It also picks the current node owner as the first trusted login and a safe available HTTPS port. Restart the DSH Web process or service you already own now. DSH loads newly installed bundles on startup; setup never guesses, kills, or restarts your supervisor. On that startup, the plugin may keep an exact private route or create and verify one absent root route to127.0.0.1:3088. It never runs Funnel, reset, or off, and it refuses a conflict.
After the restart, open the configured URL from an allowlisted Tailscale user.
That is the success criterion; port 3088 itself remains unreachable from the
LAN and tailnet. Use --print to preview without writing, or --yes for a
reviewed non-interactive run.
After setup
The saved configuration is intentionally not a claim that the URL is already live. Restart DSH Web using the process manager you already own. If the loopback port is occupied, Tailscale is unavailable, or the selected Serve route conflicts, the plugin fails closed at startup and leaves unrelated routes unchanged. Resolve the stated startup error, then restart DSH again.
Who can use it?
Setup automatically adds the current node owner to trustedLogins. That
exact Tailscale login is authorized to use DSH after the restart. It does not
add every person in the tailnet.
| Layer | What it decides | Default result for another tailnet user |
|---|---|---|
trustedLogins (required) | May this authenticated Tailscale identity use DSH? | The gateway returns 403; DSH is not forwarded. |
| Tailnet ACL/grant (optional) | May this person's network connection reach the Serve endpoint? | They may reach the endpoint unless your tailnet policy blocks it. |
In other words, an optional Tailnet grant is defense in depth for network
reachability. It does not authorize DSH use. Without a grant (and absent
another restrictive Tailnet policy), an unlisted tailnet user can reach the
endpoint, but the gateway sees their Tailscale identity and returns 403.
Without a matching trustedLogins entry, they cannot use DSH.
To share DSH deliberately, edit the generated Web-profile entry and add each person's exact Tailscale login. Every allowlisted person is a full DSH administrator; use the login identity from the Tailscale admin console, not a display name. Restart the DSH Web process you own after changing the generated entry.
- insert:
- id: dsh-tailscale-gateway-user-instance
name: dsh-tailscale-gateway
config:
trustedLogins:
- 'owner@example.invalid'
- 'another-admin@example.invalid'
Optional: restrict Tailnet reachability too
The gateway allowlist is always required. If you also want the tailnet itself to prevent non-admins from reaching the endpoint, add a narrow Tailnet policy. This generic grant is an example; replace the group member, gateway Tailscale IP, and chosen HTTPS port.
{
"groups": {
"group:dsh-admins": ["admin@example.invalid"],
},
"hosts": {
"dsh-gateway": "100.64.0.10",
},
"grants": [
{
"src": ["group:dsh-admins"],
"dst": ["dsh-gateway"],
"ip": ["tcp:8443"],
},
],
}
Grants are additive: a broader existing rule can still permit network
delivery. Review overlapping rules if you want Tailnet policy to be restrictive.
Neither a grant nor an ACL replaces trustedLogins.
Advanced: manual configuration
Most people should use guided setup. Use this section only when you need a different owner/login, a chosen canonical URL, or to manage Serve yourself. The complete sanitized template is examples/web-profile.patch.yml. It is a boot-time, legacy-compatible baseline example; guided setup creates the same kind of safe enabled entry for you.
The only normal enabled configuration keys are publicOrigin,
trustedLogins, and optional tailscaleServe. Guided setup also writes an
opaque activationToken reserved for a loopback-only diagnostic protocol; it
is not remote authentication. Leave it in place and do not share the generated
profile. Listener, upstream, TLS, OAuth, secret, and unknown keys are
rejected. publicOrigin must be the exact origin-only HTTPS *.ts.net URL
users open; logins are exact and case-sensitive.
Installation alternatives
The Quick Start uses GitHub. For development from a source checkout:
git clone https://github.com/TiantianFlow/dsh-tailscale-gateway.git
cd dsh-tailscale-gateway
dsh plugin --profile web add -w "$PWD"
If a future npm release is published, the equivalent install is:
dsh plugin --profile web add -w dsh-tailscale-gateway
Let the plugin manage its one route
This is the mode generated by setup. The public HTTPS port is derived from
publicOrigin; do not configure it a second time.
- id: dsh-tailscale-gateway
config:
enabled: true
publicOrigin: 'https://your-device.your-tailnet.ts.net:8443'
trustedLogins:
- 'replace-with-an-exact-tailscale-login@example.invalid'
tailscaleServe:
mode: ensure
After DSH has started and the sidecar has bound, ensure uses an argv-based local
Tailscale command (never a shell) to inspect serve status --json. It creates
only an absent exact root route to http://127.0.0.1:3088, verifies it, and
will not overwrite another handler or port. It never runs funnel, reset,
or off.
Manage Serve yourself
Omit tailscaleServe or set mode: manual when you want full route ownership.
After DSH starts the loopback sidecar, create the matching private route
yourself. Here the external URL and command both use port 8443:
tailscaleServe:
mode: manual
tailscale serve --https=8443 --bg http://127.0.0.1:3088
tailscale serve status --json
Do not substitute tailscale funnel: Funnel is public and does not provide the
identity header this gateway requires. Avoid tailscale serve reset unless you
intend to remove every Serve route on the node.
What it protects—and what it does not
- The listener is fixed to
127.0.0.1:3088; its only upstream is fixed to127.0.0.1:3080. - It requires exactly one Tailscale-injected
Tailscale-User-Loginheader, the exact externalHost, and the exact externalOriginfor unsafe requests,/apirequests, and WebSocket upgrades. - It strips browser credentials, client-provided proxy/Tailscale headers, and
hop-by-hop headers before forwarding, then rewrites upstream
HostandOriginto loopback. - It has no TLS listener, cookie/session store, OAuth/OIDC flow, Cloudflare dependency, DSH core patch, direct LAN listener, or public listener.
Tailscale Serve replaces a caller-supplied identity header with the authenticated user's identity before sending a request to its local backend. The gateway must therefore remain loopback-only. A same-host process could forge a loopback request, but it is already in the same local trust boundary as DSH itself. Tagged devices and Funnel traffic do not provide a usable user-login identity and are rejected.
Operations
To stop remote access while keeping the bundle installed, set enabled: false
inside the generated user-instance profile entry, then restart the DSH Web
process you own. The private Serve route intentionally persists. Remove it
separately only if you want to discard that route:
tailscale serve --https=8443 off
Before uninstalling, remove the entire setup-generated top-level - insert:
block (or leave it disabled), restart DSH, then remove the package:
dsh plugin --profile web remove -w dsh-tailscale-gateway
Removing the bundle never removes a persistent Tailscale Serve route
automatically—not even one that ensure created. The shipped disabled baseline
remains for existing installations that use the older
id: dsh-tailscale-gateway configuration; setup deliberately does not replace
or migrate that entry. DSH may still
hide controls intended only for local interaction; this gateway transports the
normal DSH UI/API and does not change DSH product policy.
Development
pnpm install --frozen-lockfile
pnpm run check
pnpm test
pnpm audit --prod
npm pack --dry-run
See CONTRIBUTING.md for contribution guidance, SECURITY.md for private vulnerability reporting, and CODE_OF_CONDUCT.md for community expectations. Maintainers can use RELEASING.md for future-release guidance.
Community
Thanks to LINUX DO for providing a space for Chinese developers to exchange ideas and feedback. This acknowledgement does not imply affiliation or official endorsement.