# @sutong/dsh-lan-access Publish the dsh Web UI on your local network with a **standalone reverse proxy**: it listens on `0.0.0.0:`, forwards HTTP and WebSocket to the loopback web server, and **keeps the official launch-token login**. [简体中文](README.md) | **English** ## Why a plugin is needed `dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose (`@deepseek-ai/dsh-web-app/lib/startup.js`: it would expose this machine's remote code execution to the network). The official web server therefore always binds loopback, so LAN access has to be a second listener that forwards to it. ## How it differs from dsh-lan-proxy | | This plugin | dsh-lan-proxy | |---|---|---| | Default login | **the launch token is required** (the link from the banner or the QR code) | `injectToken: true` — anyone who can reach the port gets in untokened | | Upstream `Host` | rewritten to loopback, so the official fence passes | rewritten to loopback | | `Set-Cookie` | left untouched (a browser binds a host-only cookie to the **request URL**, so the forwarder needs no rewrite) | paired with token injection and a 401 replay | | WebSocket | handshake forwarded byte-for-byte; `permessage-deflate` negotiates end to end and **the proxy decompresses nothing** | compression bridge (adds a decompression surface) | | Shipped rows | **overrides none**, so a failure or a manual disable cannot affect the loopback UI | inserts its own row plus a compatibility injection | > **Port collision**: dsh-lan-proxy claims `3081` by default, so this plugin defaults to `3082`. They can coexist, but running both exposes the UI twice — keep one. ## Install ```sh dsh plugin --profile web add /path/to/dsh-lan-access ``` Then restart `dsh web` once. The terminal prints the tokenized LAN links. ## Configuration Set the row's `config` (Host Profile → Plugins → this plugin → Configure, or edit `cordis.patch.yml`). | Key | Default | Meaning | |---|---|---| | `enabled` | `true` | Master switch; off means no listener | | `host` | `0.0.0.0` | Listen address | | `port` | `3082` | Listen port (avoids dsh-lan-proxy's 3081) | | `targetHost` | `127.0.0.1` | Upstream host. **Loopback only** — anything else is refused at startup, so this can never become an open proxy | | `targetPort` | `0` | Upstream port; `0` follows the live `webServer` port | | `printBanner` | `true` | Print the LAN links at startup | ## Usage 1. **Startup banner**: ``` dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener) dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX ``` 2. **Web UI panel**: Settings → **LAN Access** (the plugin owns this settings page). It lists every LAN address with copy/open actions and renders a **QR code** for the address you select, so a phone can scan straight in. The `token` is used only on the first visit: the server mints a 30-day cookie and 302s to the clean `./`, so **the token never stays in the address bar or history**. That device then enters by cookie. ## Security model - **Authentication is not relaxed.** No token injection, no fence bypass: a LAN device must hold the link from the banner or the QR code. That is stricter than trusting the whole network. - **The official fence stays in force.** `/api` accepts only loopback or declared authorities, and `Origin` must match `Host`; the proxy rewrites the upstream `Host`/`Origin` to loopback so those checks *pass* rather than being bypassed. - **DNS-rebinding guard**: the listener accepts only `localhost`, loopback, the LAN IP literals it publishes, or its own listen address. Hostnames are dropped. - **Upstream locked to loopback**, so it cannot be turned into an open forward proxy. - **No extra decompression surface**: no response or frame compression in the middle. - **The panel's data route** (`/api/dsh-lan-access/summary`) is mounted on the official web server and **reuses the official `connection.admit()`** — the same fence and browser authentication — so only an authenticated page can read the token. - **⚠️ Blast radius**: a device holding that link has **full dsh control, including command execution on this host**. Enable it only on a trusted network, and disable or uninstall it when you are done. Every link in the list is a key. ## Settings screens on a LAN page (`ownsHostCompat`) DSH restricts the durable settings surface to loopback pages: the client derives `isLoopback` from `location.hostname` (`dsh-client-connection/lib/client.js`), so a non-loopback page gets `persistence = "memory"` (`dsh-client-ui-settings/lib/client.js`), the settings mirror never answers with a `view`, and the Models/provider page reports **settings are unavailable in this browser** — while the General page's "open config file" action disappears. **This is unrelated to forwarding correctness**: every LAN exposure method hits it, and `127.0.0.1:3080` is unaffected. There is no matching server-side gate (the `/api` fence plus the launch token and cookie are the whole authorization surface), so this plugin exposes a switch: | Key | Default | Meaning | |---|---|---| | `ownsHostCompat` | `false` | Declare `ownsHost` for **non-loopback pages**, restoring the official settings screens (a loopback page is untouched) | This profile sets it to `true`. The tradeoff: that page can then read and write server settings, credentials included. Since a device holding the tokenized link already has full control (command execution on this host), this is not a new class of capability — but it does step past the shipped loopback isolation. Set it to `false` to restore that posture and change settings locally through `127.0.0.1:3080` (or an `ssh -L 3080:127.0.0.1:3080 ` tunnel). ## The directory picker behind "Add workspace" `directory-picker-auto` picks ONE backend at boot from **host-side** facts: `native` requires "a loopback-only bind (an all-interfaces bind admits remote browsers no OS chooser can reach)" plus a servable display. This bundle deliberately keeps the official webserver on loopback (the LAN is a separate listener on 3082), so a LAN deployment still resolves to `native` — and the dialog opens on the **server machine**, where the remote operator can neither see nor reach it. This bundle's patch therefore pins the `browse` pair, the documented way to force an interaction ("pinning an interaction remains composing that pair directly instead of this row"): - `@deepseek-ai/dsh-host-directory-picker-browse` (backend) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` (surface) That backend lists the server's directory tree inside the page; upstream describes it as serving "remote clients the dialog backend cannot". **Tradeoff**: the seam mounts exactly one backend per process, so a **local page switches to the in-page browser too** — there is no "native locally, in-page remotely" combination. To restore the adaptive choice, remove the last three entries of `cordis.patch.yml` (the `- id: directory-picker / disabled: true` entry and the `insert` block after it). ## Troubleshooting | Symptom | Cause and fix | |---|---| | `LAN listener unavailable ... EADDRINUSE` | The port is taken (commonly dsh-lan-proxy on 3081). Change `port`, or disable the other plugin. The panel shows the same error instead of a dead QR code. | | A LAN device gets 401 | It opened the bare address without a token and has no cookie yet. Use the `?token=` link from the banner or the panel. | | A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. | | The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. | | Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. | ## Development ```sh node scripts/build-client.mjs # generates client.js from src/panel.js + src/qrcode.js node scripts/smoke.mjs # host forwarding contract (HTTP/WS/Host/Origin/cookie/bind failure) node scripts/test-qr.mjs # module-by-module comparison against the npm qrcode reference node scripts/test-client.mjs # browser contract (slot registration, render, route agreement) ``` `client.js` is generated: edit `src/` and rebuild. The QR encoder (`src/qrcode.js`) is a dependency-free implementation (byte mode, ECC M, versions 1–10) verified module-by-module against the npm `qrcode` reference across every version and every mask (240/240 identical). ## Rollback Disable or uninstall the plugin from the Plugins page. Because it **overrides no shipped row**, disabling it only removes the LAN listener; loopback `dsh web` is unaffected. ## License MIT