{"_id":"@creait/dsh-tailnet-gateway","name":"@creait/dsh-tailnet-gateway","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@creait/dsh-tailnet-gateway","description":"Reach a loopback-bound dsh from your own Tailscale devices, with Settings, Models and Plugins working, gated on Tailscale identity and an optional device allowlist.","version":"0.1.0","type":"module","main":"lib/index.js","exports":{".":{"default":"./lib/index.js"},"./client":"./client/client.cjs","./package.json":"./package.json"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"},"client":{"inject":["@deepseek-ai/dsh-client-runtime","@deepseek-ai/dsh-client-locale"],"platform":"web"}},"dependencies":{"@deepseek-ai/dsh-settings":"^0.1.0-rc.6","@deepseek-ai/schemastery":"^3.18.1"},"peerDependencies":{"@deepseek-ai/cordis":"^4.0.1"},"license":"MIT","engines":{"node":">=20"},"keywords":["dsh","deepseek-harness","cordis","plugin","tailscale","tailnet","gateway","remote-access"],"repository":{"type":"git","url":"git+https://github.com/CREAIT-nl/dsh-plugins.git","directory":"tailnet-gateway"},"homepage":"https://github.com/CREAIT-nl/dsh-plugins/tree/main/tailnet-gateway#readme","bugs":{"url":"https://github.com/CREAIT-nl/dsh-plugins/issues"},"author":{"name":"Francesco G","email":"francesco@creait.nl"},"scripts":{"test":"node --test test/*.test.js"},"gitHead":"c6086194b046a24cdd782ddba46961151a1e5388","_id":"@creait/dsh-tailnet-gateway@0.1.0","_nodeVersion":"25.8.1","_npmVersion":"11.19.0","dist":{"integrity":"sha512-jZYlD5BoPGHoeAHaGFvcTP+ph7bJ10mX1v9fhO3nTR4T4MDHtuA/0Gyl3ufomSJr5NSjZy2qA3jR4I9kdvee9g==","shasum":"339f59c189284ac7b8a0b6d0c1c96542b9f3d1e2","tarball":"https://registry.npmjs.org/@creait/dsh-tailnet-gateway/-/dsh-tailnet-gateway-0.1.0.tgz","fileCount":10,"unpackedSize":77493,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIBBTTfkaXEr81yUn29ne5vTWy4NQSgKIq+JvGpmueDgjAiAZbF+NIXeLytlYWrpv2oZMlCLBz5ghWTAvJUuuH9nwqw=="}]},"_npmUser":{"name":"creait","email":"francesco@creait.nl"},"directories":{},"maintainers":[{"name":"creait","email":"francesco@creait.nl"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/dsh-tailnet-gateway_0.1.0_1787733717404_0.9875590120068294"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-26T08:41:57.203Z","0.1.0":"2026-08-26T08:41:57.535Z","modified":"2026-08-26T08:41:57.698Z"},"maintainers":[{"name":"creait","email":"francesco@creait.nl"}],"description":"Reach a loopback-bound dsh from your own Tailscale devices, with Settings, Models and Plugins working, gated on Tailscale identity and an optional device allowlist.","homepage":"https://github.com/CREAIT-nl/dsh-plugins/tree/main/tailnet-gateway#readme","keywords":["dsh","deepseek-harness","cordis","plugin","tailscale","tailnet","gateway","remote-access"],"repository":{"type":"git","url":"git+https://github.com/CREAIT-nl/dsh-plugins.git","directory":"tailnet-gateway"},"author":{"name":"Francesco G","email":"francesco@creait.nl"},"bugs":{"url":"https://github.com/CREAIT-nl/dsh-plugins/issues"},"license":"MIT","readme":"# @creait/dsh-tailnet-gateway\n\nReach a loopback-bound DeepSeek Harness from your own Tailscale devices — with\nSettings, Models and Plugins actually working — gated on Tailscale identity and,\noptionally, on which specific machine is asking.\n\n## The problem\n\nPoint a browser on your tailnet at a dsh instance bound to `0.0.0.0` and you get\nan app that is two-thirds working. Chat is fine. Settings says settings are\nunavailable in this browser, Models will not load, Plugins → Plugin\nconfiguration is empty, and the agent-preset picker is greyed out.\n\nThat is not a bug to route around. `@deepseek-ai/dsh-client-connection` puts a\ndeliberate fence in front of a set of privileged RPC methods —\n`settings.describe/update/replace/mutate`, `credentials.describe/set/unset`,\n`agentPreset.*`, `host.pickDirectory`, `host.openPath`, `llm.discoverModels` —\nand lets them through only from loopback. It is defending against DNS rebinding\nand cross-site calls, and it means it: the privileged branch re-runs the trust\ncheck with an **empty** trusted-host list, so `--trusted-host` cannot open it.\nAnything else gets `403 forbidden`.\n\nThe client half compounds it. It decides `isLoopback` from\n`window.location.hostname` alone, so over a tailnet URL the settings pages build\na throwaway in-memory store and never send the RPC at all — which is why the\nsymptom reads as a broken page rather than a permission error.\n\nSo binding to `0.0.0.0` buys a half-working app *and* exposes dsh to the whole\nLAN. Neither half of that trade is good.\n\n## The shape of the fix\n\ndsh stays on `127.0.0.1` and is never directly reachable. This plugin listens on\na second loopback port; `tailscale serve` publishes **that** port to the tailnet\nover TLS. Every request arriving there has already been through `tailscaled`,\nwhich terminates the TLS, stamps `Tailscale-User-Login` and `X-Forwarded-For`,\nand **overwrites whatever the client sent** — which is what makes the identity\nunforgeable. Requests that pass the gate are forwarded to dsh as what they\ngenuinely are: a loopback call from the same machine. Requests that fail are\nrefused with 403 and never touch dsh at all.\n\n```\ntailnet device ──TLS──▶ tailscale serve ──▶ gateway :7242 ──▶ dsh :7241\n                        (stamps identity)   (gate + rewrite)   (127.0.0.1)\n```\n\n## Two gates\n\n**Login.** The Tailscale account behind the request must be allowed. An empty\nallowlist means *the account that owns this node*, so a single-user tailnet\nneeds no configuration to be correct. This gate alone already excludes tagged\nservers — a machine joined with an auth key has no human owner and its login\nreads as `tagged-devices`.\n\n**Device.** The specific machine must be on the allowlist, matched by its short\ntailnet name (`laptop`, not an IP, which moves). Off by default, because an\nempty allowlist with the gate on locks out the device that would turn it on. Fill\nthe list first, then switch it on. This is what makes \"my phone and my laptop,\nnever that VPS\" expressible even when the VPS is signed in as you.\n\nBoth gates fail closed on a missing fact: no identity header means no admission,\nand an unresolvable peer means no admission while the device gate is on — a\nrequest that did not come through `tailscale serve` therefore fails both. If\n`tailscale status` cannot be read at all, the last known peer table is kept\nrather than an empty one, because a tailscaled restart must not silently suspend\nthe allowlist.\n\n## Settings → Tailnet Access\n\nA top-level settings page: gateway on/off with its live listening address and\nthe `tailscale serve` line to publish it, the login gate and its allowlist, the\ndevice gate and every peer on your tailnet with a one-click Allowed/Blocked\ncontrol, and the loopback override below.\n\nThe config route refuses a write that would lock **you** out — turning the\ndevice gate on without ticking your own laptop is answered with an error rather\nthan an unreachable machine, and the only way back from that would be a shell on\nthe box.\n\n## The loopback override\n\n`trustGatewayClients` (on by default) is what fixes the client half. The client\ncannot be told the truth at runtime: `dsh-client-ui-settings` reads\n`connection.isLoopback` inside its own `apply()`, and the connection plugin\ncomputes it inside `apply()` too, so a third plugin mutating the service\nafterwards would be racing a value that has already been read. The honest place\nto state it is the served bundle, so the gateway substitutes that one line as it\nproxies `/plugins/@deepseek-ai/dsh-client-connection/client.js`.\n\nIt is a single literal substitution, not a parse, on a file dsh serves\nuncompressed. If a dsh upgrade changes the line the substitution simply does not\nmatch: the app keeps working, the settings pages fall back to their stub store,\nand the log says so once.\n\nTurning this off leaves the gateway doing its access half and gives the settings\npages back their remote behaviour — which is to say, back to not working.\n\n## Why it must stay on loopback\n\nThe gateway's whole job is to convert an authenticated tailnet request into a\nloopback one, so it holds loopback trust. Bound to a public interface it would\nhand that trust to anyone who asks, because a direct caller can set both headers\nitself. `resolveConfig` clamps the bind address to `127.0.0.1`/`::1` and\n`startGateway` refuses to listen anywhere else.\n\n## Install\n\n```bash\ndsh plugin --profile web add @creait/dsh-tailnet-gateway\n```\n\nRestart `dsh web` afterwards: the boot manifest is built at startup.\n\nThen bind dsh itself to loopback — it must not be reachable on the tailnet\ndirectly, or the gate is just a second door into an already-open room — and\npublish the gateway:\n\n```bash\ntailscale serve --bg <gateway port>\n```\n\nSettings → Tailnet Access shows that command with the port it actually bound.\n\n## Settings\n\n| Key | Default | Meaning |\n| --- | --- | --- |\n| `enabled` | `true` | Run the listener at all. |\n| `host` | `127.0.0.1` | Bind address; loopback only, enforced. |\n| `port` | `7242` | The port `tailscale serve` publishes. |\n| `requireLogin` | `true` | Demand a Tailscale identity on every request. |\n| `allowedLogins` | `[]` | Empty means the login that owns this node. |\n| `deviceAllowlist` | `false` | Also gate on which machine is asking. |\n| `allowedDevices` | `[]` | Short tailnet names, e.g. `laptop`. |\n| `trustGatewayClients` | `true` | Tell the served client this hop is loopback. |\n| `statusTtlMs` | `30000` | How long one `tailscale status` read is reused. |\n\n## Tests\n\n```sh\nnode --test test/*.test.js\n```\n\n`gate.test.js` pins the admission table and the header rewriting; `proxy.test.js`\ndrives a real gateway over real sockets against a stand-in dsh, and checks the\nthings only a running server shows — that a refused request never reaches the\nupstream at all, that the bundle is rewritten in flight, and that an upgrade is\ntunnelled rather than answered.\n\n## What breaks this\n\nThe loopback override is a text substitution against a build artifact. The\nclient bundle is minified but not mangled beyond recognition, and the marker it\nlooks for is the compiled form of one property initialiser in\n`dsh-client-connection`. A harness upgrade that renames the helper, reorders the\nobject, or changes how the hostname is read moves that marker; the rewrite then\nmatches nothing. It fails open in the safe direction — the bundle is passed\nthrough byte-for-byte, the access half keeps working, and the settings pages go\nback to being read-only over the tailnet — and it says so once in the log rather\nthan silently. If that happens, either update the marker or turn\n`trustGatewayClients` off until you do.\n\nBoth gates rest on `tailscale serve` stamping `Tailscale-User-Login` and\n`X-Forwarded-For` and, crucially, on it *overwriting* whatever the client sent.\nThat is what makes the headers evidence rather than a claim. Anything else in\nfront of the gateway — a reverse proxy, a port-forward, a second hop — removes\nthat guarantee, which is why the bind address is clamped rather than\nconfigurable. A gateway reachable from anywhere but loopback is a gateway that\nhands loopback trust to whoever asks for it.\n\nIdentity is resolved by shelling out to `tailscale status --json` and reading\n`Self`, `Peer` and `User`. Those field names are stable across the versions this\nwas built against but are not a documented API. A read that fails keeps the last\ngood table rather than falling back to an empty one, so a transient tailscaled\nrestart refuses nothing; a permanently broken read means the device gate refuses\neverything, which is the direction you want to fail in.\n\n`settings.section` is a pre-1.0 slot and the config routes live on the plugin's\nown paths rather than the settings RPC, because that RPC is behind the same\nbrowser-trust fence this plugin exists to cross. `peerDependencies` pins the\nversions this was built against; a harness upgrade can move them.\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-d469e569fc2525215fecce9482c69721"}