Back to home@TiantianFlow

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
GitHub repo

Introduction

dsh-tailscale-gateway

CI status MIT license DSH Web-profile bundle Tailscale Serve Node.js 20 or later

English · 简体中文

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 needWhat this bundle does
Share DSH with only specific peopleAllows exact Tailscale-User-Login identities, not every Tailnet member who can reach the node.
Keep DSH localPins 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 remotelyGuards and proxies the UI, HTTP API, and WebSocket event streams, then forwards to DSH as its fixed loopback origin.
Avoid route accidentsSetup infers the node owner and a safe available HTTPS port; ensure creates only its one absent route and refuses conflicts or Funnel.
Preserve existing servicesIt 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.

  1. Install the inert bundle. This alone neither starts a listener nor changes Tailscale.

    dsh plugin --profile web add -w github:TiantianFlow/dsh-tailscale-gateway
    

    -w is required because DSH's Web profile is a pnpm workspace root.

  2. 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-setup
    

    Your 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 to 127.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.

LayerWhat it decidesDefault 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 to 127.0.0.1:3080.
  • It requires exactly one Tailscale-injected Tailscale-User-Login header, the exact external Host, and the exact external Origin for unsafe requests, /api requests, and WebSocket upgrades.
  • It strips browser credentials, client-provided proxy/Tailscale headers, and hop-by-hop headers before forwarding, then rewrites upstream Host and Origin to 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.

License

MIT