dsh-plugin-remote-connect-beta 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,115 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [0.1.0-beta.1] - 2026-09-19
6
+
7
+ First public beta. Installed with `npm i dsh-plugin-remote-connect-beta@beta` (the `latest` tag is left
8
+ untouched on purpose), or straight from the repository.
9
+
10
+ ### Added
11
+
12
+ - Password de-duplication on the Mac side, per the updated spec: a new access password is refused if it
13
+ matches the current one or any key that has been active before (only SHA-256 16-hex fingerprints are
14
+ kept, never old plaintext), with "already in use, pick another" and no hint of whose it is. The check
15
+ runs only after the current password was verified and shares a lock with the write, so concurrent
16
+ changes cannot both pass.
17
+ - When the upstream cannot be reached (tunnel down / Harness not running) the entry now answers 503 with
18
+ a readable page and `X-DSH-Reason: tunnel-down` instead of passing a bare 502 through.
19
+
20
+
21
+ - Diagnostics for the second gate, per the server side's confirmation document: failures now carry
22
+ `X-DSH-Reason` (`no-key` / `bad-key` / `key-unusable` / `host-not-allowed`) while keeping HTTP 404,
23
+ each reason gets its own page copy, and the plugin logs `reason= key_len= key_fp8=` (a hash prefix,
24
+ never the key). `GET /_dsh/health` answers without a key and reports tunnel state, key fingerprint,
25
+ creation time, rotation count, last success and the last 24 h of failures by reason.
26
+ - The access key is now **persisted before it is displayed** (0600, with `createdAt` / `rotations`), so
27
+ restarts, tunnel reconnects and reboots never change it; the panel shows fingerprint, creation time
28
+ and rotation count plus a copy-link button.
29
+ - The launch token is no longer put in the browser's URL: a stale session is healed by re-logging in
30
+ **server-side** and relaying the cookie, so the token never reaches history or Referer.
31
+
32
+
33
+ - Changing the access password now requires the current one (re-authentication), per the server side's
34
+ spec: the panel asks for current + new + confirmation, verifies the current value through
35
+ `POST /access-key/verify`, and only then writes. A failed verification writes nothing, five
36
+ consecutive failures trigger a five-minute cooldown, and every failure or reset appends a line to
37
+ `audit.log` that never contains a password. **Reset** (forgotten password) is a separate, explicitly
38
+ confirmed path that invalidates old links and sessions. The one-click "generate new" action is gone.
39
+
40
+
41
+ - **`tailscale` tunnel mode** (`--tunnel tailscale` / `public.tunnel: tailscale`): publishes the loopback entry port with `tailscale funnel --bg`, reads the node address from `tailscale status --json`, probes the funnel periodically, and removes only its own mapping on stop. Failure paths (client missing, logged out, Funnel not enabled) surface a translated hint instead of retrying forever.
42
+ - **Host-side message catalog** (`lib/core/messages.js`, one key set per language) so preflight results, tunnel state and panel API errors render in the language the panel asks for. The panel now sends `?locale=` with every API call; unknown locales fall back to English.
43
+ - **CLI language support**: `--lang <en|zh>`, plus `DSH_REMOTE_LANG` / `LC_ALL` / `LANG` detection (English by default). `--help`, check results, the serve banner and the common errors are bilingual. `doctor`, `setup-server` and `keygen` still print their detailed report in Chinese and now say so in one line under an English locale.
44
+ - `doctor` reports the tailscale funnel check when the configured tunnel mode is `tailscale`.
45
+ - CI workflow (Node 20/22: gate → tests → pack-content check), `CONTRIBUTING.md`, Homebrew formula template and `docs/market-submission.md`.
46
+
47
+ ### Added
48
+
49
+ - **Multi-tenant gateway**: each tenant gets their own Harness process — own `DSH_HOME`, own loopback port, own launch token, own credentials. The key gate resolves the tenant from `?k=`/cookie and routes that request to that tenant's upstream with that tenant's token; the cookie is signed with a secret independent of every tenant key.
50
+ - `lib/core/tenant.js` (registry, 0600, atomic writes, duplicate id/key rejection, tolerant loading), `lib/core/instance.js` (per-tenant supervisor: real-node spawn, token from the child's own stdout, backoff restart, per-tenant `instance.log`), `lib/core/tenancy.js` (orchestration and routing handles), `lib/core/paths.js` (shared state paths).
51
+ - Panel: a Tenants card — add by name, per-tenant link and QR, start/stop, rotate key, remove, live state and actionable errors.
52
+ - CLI: `tenant list|add|rm|rotate|key` (registry only) and `serve --multi` (gateway + instances in one process, torn down on exit).
53
+ - Text is bilingual, and the new `docs/multi-tenant.md` / `.zh.md` explain the isolation model, the registry format, the real-node gotcha and the operating questions.
54
+ - WebSocket upgrades now pass the same key gate as ordinary requests (they previously bypassed it).
55
+
56
+ ### Documentation
57
+
58
+ - `docs/self-host.md` + `docs/self-host.zh.md`: the long-form guide to the `selfhost` backend — link shape, scripted vs manual server setup, TLS with served-vs-on-disk fingerprint verification, the restricted tunnel account, DNS with multiple views, client configuration, verification commands and a troubleshooting table.
59
+ - `package.json` now ships `docs/` (the guides and the preview image) so every README link resolves in the published tarball; CI checks those files are present in the pack.
60
+
61
+ ### Changed
62
+
63
+ - `npm run gate` also rejects hardcoded private LAN addresses (`192.168.x.x` / `10.x.x.x` literals); `192.168.x.x`-style placeholders still pass.
64
+ - Preview harness (`npm run preview`) can render the tailscale backend (`?mode=tailscale`) and take documentation screenshots (`?shot=1&zoom=0.72`); `docs/client-preview.png` was regenerated from it.
65
+ - Repository initialized with an initial commit and the packaging/CI files in place, so publishing is `git remote add` + `npm publish` away.
66
+
67
+ ### Added
68
+
69
+ - Credential defaults follow the server side's spec: the edge **user name** defaults to `dsh` and is
70
+ renameable (`--edge-user`, the installer deletes the previous entry so the old name stops working);
71
+ the edge **password** has no default — `--edge-password auto` generates one and prints it once,
72
+ `prompt` defers to the installer's interactive prompt, an explicit value is strength-checked. The
73
+ installer now uses `-i -B` (stdin + bcrypt) and only passes `-c` when the file does not exist yet.
74
+ - The CI gate also rejects built-in default passwords and the author instance's sample password.
75
+ - The panel warns that on the `tailscale` backend the access password is the only protection
76
+ (Funnel has no identity gate of its own).
77
+
78
+ ### Fixed
79
+
80
+ - **Tunnel reconnect no longer hammers the server.** The counter was reset on every exit, so the
81
+ backoff never grew past its first step (a fixed ~2 s retry loop, 30 connections/minute). Hosts
82
+ that rate-limit SSH port 22022 (a common firewall rule: 20 new connections per minute per IP)
83
+ answer that with silent timeouts. Reconnects now back off exponentially with jitter (5 s → 60 s,
84
+ ±25 %) and only reset after the tunnel has been stable for two minutes.
85
+ - The generated `authorized_keys` line now uses `remote-port-forwarding` instead of
86
+ `port-forwarding`, so the tunnel account cannot open local forwards either.
87
+ - `credential` now emits `htpasswd -i -B` (bcrypt) and no longer uses `-c`, which would wipe other
88
+ users in an existing htpasswd file; first-time creation is shown separately.
89
+ - A request that arrives with a wrong or rotated `?k=` now gets a short explanation page instead of
90
+ a bare 404 (requests without any credential still get the bare 404, so the entry's existence is
91
+ not revealed to scanners).
92
+
93
+ - `dsh-remote --help` was treated as an unknown command (`--help` must be the first argument to be parsed as a flag).
94
+ - Panel no longer crashes when the first `/state` request fails (`data` is still `null`).
95
+ - `public.domain` is now required only for the `ssh` tunnel mode; `cloudflared` and `tailscale` provide their own hostname.
96
+
97
+ ## [0.1.0] - 2026-09-19
98
+
99
+ ### Added
100
+
101
+ - **`lan` backend**: reverse proxy that binds all interfaces, injects a mobile-adaptation stylesheet and shim into the Harness index, prints a terminal QR code, and serves an authenticated browser session.
102
+ - **`selfhost` backend**: `ssh -R` tunnel supervision with exponential backoff, plus `setup-server` / `uninstall-server` generators that emit an idempotent installer (`probe` / `install` / `uninstall` / `--dry-run` / `--skip-*`).
103
+ - **`cloudflare` tunnel mode** (`serve --tunnel cloudflared`) for users without a server.
104
+ - **`doctor`**: upstream and token provenance, proxy self-test, DNS / certificate / edge password / ssh tunnel checks, the certificate actually served, `--expect-cert-sha256` cross-machine comparison, and the checks only the server can run.
105
+ - **DSH plugin halves**: host half (`lib/index.js`) with panel routes and in-process proxy/tunnel lifecycle; client half (`lib/client.js`) as a hand-written bundle with no build step.
106
+ - **Bilingual panel**: zh/en dictionaries with `locale` as a soft dependency (29 keys each as of the host-catalog change).
107
+ - **Zero-dependency `Config`**: a Standard Schema validator so malformed configuration fails before activation without adding a dependency.
108
+
109
+ ### Security
110
+
111
+ - `Host`/`Origin` rewrite and launch-token injection so the Harness API fence and browser authentication work through any ingress.
112
+ - Access-key gate (`?k=`) answers 404, never 401, and rotates without touching the edge.
113
+ - Generated server template redacts the request line so the access key never reaches an access log.
114
+ - Public mode refuses to start without an access key; Harness upstream always binds loopback.
115
+ - Repository gate (`npm run gate`) rejects author-private values in every committed file.
@@ -0,0 +1,50 @@
1
+ # Contributing
2
+
3
+ [English](CONTRIBUTING.md) | [中文说明见 README](README.zh.md)
4
+
5
+ ## 开发环境
6
+
7
+ - Node.js ≥ 18(CI 跑 20 与 22)
8
+ - macOS 或 Linux;`Bash` 需要能跑 `bash -n`
9
+ - `npm install` 后即可跑测试(React 仅作为 devDependency,用于客户端半的 SSR 断言)
10
+
11
+ ```bash
12
+ npm install
13
+ npm test # 契约 / 渲染 / 生成物 / 本地化断言
14
+ npm run gate # 私有值门禁:自有域名、服务器 IP、本机路径、真实公钥
15
+ npm run e2e # 端到端:起一个隔离的真实 DSH 实例并把本仓库当插件装进去(需要本机有 DSH 安装)
16
+ npm run preview # 真浏览器预览客户端半:http://127.0.0.1:8899/
17
+ ```
18
+
19
+ `npm run e2e` **只在本地跑**:它需要一份真实的 DSH 安装(默认 `/Applications/DSH Desktop.app`,可用 `APP=` 覆盖)。CI 不跑它,因为 runner 上没有 DSH。
20
+
21
+ ## 代码约定
22
+
23
+ - **零运行时依赖**是硬约束:宿主半只用 Node 内置模块与 Cordis 上下文,客户端半只 `require` 运行时 seed 模块(`react`)与可选的 `@deepseek-ai/dsh-client-ui-primitives`。**不要**引入 `@deepseek-ai/schemastery` —— 插件装在各用户的 profile 目录下,那里解析不到 harness 自己的 node_modules,静态 import 会直接让插件装载失败。配置校验用零依赖的 Standard Schema(见 `lib/index.js` 的 `Config`)。
24
+ - **生成物必须过解析器**:`lib/core/assets/*.tpl` 渲染出的脚本会被 `bash -n` 校验(测试里已固化)。改模板后必须跑 `npm test`。
25
+ - **不要往生成脚本里插未校验的输入**:域名/用户名/路径都会被拼进 bash。新加参数时同步在 `lib/core/serversetup.js` 的校验里加规则,并补测试。
26
+ - **文案归属**:面板自己的界面文案在 `lib/client.js` 的 zh/en 字典里(组件只通过 `t()` 取词,不写字面量);**宿主生成**的文案(前置检查结果、隧道状态、接口错误、CLI)在 `lib/core/messages.js` 的同一目录里。宿主半只发 `code` + `params`,由调用方按语言渲染——面板请求带 `?locale=`,CLI 用 `--lang`/环境变量。两份字典/目录的键集一致性与占位符一致性都有测试兜底。
27
+ - **新增后端时**:与语言无关的 argv 构造、输出解析、报错分类放进独立模块(参考 `lib/core/tailscale.js`),并写成纯函数;`lib/core/tunnel.js` 只负责生命周期与状态机。失败路径不要自动无限重试(配置类失败重试也不会好)。
28
+ - **注册即副作用**:host 半的每一次注册都通过 `ctx.effect()` 持有 disposer,保证卸载时端口与隧道被回收(测试会验证端口真的释放)。
29
+ - **文档成对更新**:`README.md`/`README.zh.md`、`docs/self-host.md`/`docs/self-host.zh.md` 各是一对,改一边就改另一边。事实只写一处,其余用链接;`docs/market-submission.md` 是给自己 fork 后上架用的(不进读者路径)。新增面向读者的文档时,记得同步 `package.json` 的 `files` 与 CI 的发布包检查——README 里链到的文件必须在包里,否则 npm 页面上是死链。
30
+ - **提交前**:`npm test && npm run gate`。
31
+
32
+ ## 发布前检查清单(fork 后第一次发布必须做)
33
+
34
+ - [ ] `package.json` 的 `name` 换成你自己的 scope(`@you/dsh-plugin-remote-connect-beta`),或确认无 scope 名未被占用
35
+ - [ ] 补 `repository` / `homepage` / `bugs` / `author` 字段(本仓库刻意留空,避免把占位地址发上 npm)
36
+ - [ ] `LICENSE` 的版权行换成你的名字或组织
37
+ - [ ] `SECURITY.md` 里换成你的漏洞披露渠道
38
+ - [ ] 更新 `CHANGELOG.md` 与 `version`
39
+ - [ ] `npm run gate && npm test` 全绿
40
+ - [ ] `npm pack --dry-run --json` 确认发布包包含 `bin/` 与 `lib/core/assets/`
41
+ - [ ] `npm publish --provenance --access public`(在 GitHub Actions 里发可获得溯源)
42
+ - [ ] 打 tag 并做 GitHub Release;如做 Homebrew tap,用 `packaging/homebrew/dsh-remote.rb` 模板填 sha256
43
+ - [ ] 上架材料见 `docs/market-submission.md`
44
+
45
+ ## 不接受的改动
46
+
47
+ - 任何形式的"托管 / 中转服务"(本项目明确不提供,见 README 与 SECURITY)
48
+ - 让 Harness 监听 `0.0.0.0` 的选项
49
+ - 关闭或弱化访问密钥门的选项(`serve --public` 无密钥必须拒绝启动)
50
+ - 把 `?k=` 或口令写进日志、错误信息、遥测的改动
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hwc-cell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,279 @@
1
+ # dsh-plugin-remote-connect-beta
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ > ⚠️ **Read this first.** This plugin exposes an agent that **runs commands and reads/writes files on your machine** to the network. If it is compromised, your machine is compromised. Treat it as "publishing a host that can execute arbitrary commands", not as an ordinary web tool.
6
+ >
7
+ > The author **does not operate any hosted or relay service**, and no live instance address appears in these docs — bring your own server and domain. Threat model, credential handling, and vulnerability reporting live in [SECURITY.md](SECURITY.md).
8
+
9
+ Give a **DSH Harness** a remote entry point: over your local network, or over the public internet through your own server. Any phone, tablet, or computer with a modern browser can use it — no client to install, no VPN.
10
+
11
+ Two ways to use it, one shared core:
12
+
13
+ - **DSH plugin** — a "Remote access" panel above *Settings* in the sidebar, with switches, addresses, and a QR code.
14
+ - **Standalone CLI** — `dsh-remote serve / check / doctor / snippets / keygen / setup-server / uninstall-server`, usable without DSH and convenient for debugging on a server.
15
+
16
+ ---
17
+
18
+ ## Who runs this, and where
19
+
20
+ This plugin is meant to be **installed by whoever wants a remote entry, on their own machine**.
21
+ It is MIT-licensed and dependency-free on purpose: `npx dsh-plugin-remote-connect-beta serve` is the
22
+ whole onboarding, and nothing ever talks to a relay or to an account of ours.
23
+
24
+ One consequence is worth stating plainly: **a Harness runs on the machine where it is installed,
25
+ and it can execute commands there.** Handing out access means handing out access to that computer.
26
+ So when a second person wants in, the recommended answer is "install it on your own machine", not
27
+ "use mine".
28
+
29
+ For the case where one machine genuinely serves several people (home server, shared workstation),
30
+ the plugin has a multi-tenant layer: each tenant gets their **own** Harness process with its own
31
+ `DSH_HOME`, port and launch token, and the gateway routes each access key to its own instance.
32
+ It is **off by default**, and the trade-offs are spelled out in
33
+ [`docs/multi-tenant.md`](docs/multi-tenant.md).
34
+
35
+ ---
36
+
37
+ ## Four backends (pick one; same core)
38
+
39
+ | Backend | Work required on the server | Who it suits | Reachability in mainland China |
40
+ | --- | --- | --- | --- |
41
+ | `lan` | **0 items** | Phone/computer on the same Wi-Fi | No external dependency ✅ |
42
+ | `tenants` | **0 items** | Several people, each with their own Harness — see [`docs/multi-tenant.md`](docs/multi-tenant.md) | Depends on the entry above |
43
+ | `cloudflare` | **0 items** (install `cloudflared`, authorize; brings its own certificate, Cloudflare Access available) | Most people | ⚠️ unstable |
44
+ | `tailscale` | **0 items** (`tailscale funnel`; brings its own certificate and domain) | People who prefer not to use Cloudflare | ⚠️ unstable |
45
+ | `selfhost` | **6 items**: DNS / certificate / nginx reverse proxy / edge password / dedicated ssh account / self-test — step-by-step guide: [`docs/self-host.md`](docs/self-host.md) | People with a VPS and their own domain who want full control | ✅ recommended for mainland users |
46
+
47
+ > Hosted tunnels are **unreliable from mainland China**, so `selfhost` is a first-class backend rather than a patch: for those users, "own VPS + own domain" is usually a hard requirement.
48
+
49
+ **Implementation status (honest, not marketing):**
50
+
51
+ | Capability | Status |
52
+ | --- | --- |
53
+ | `lan` backend (panel + CLI + mobile layout + QR) | ✅ implemented; verified end to end |
54
+ | `selfhost` backend (ssh -R tunnel, config generation, preflight) | ✅ implemented; the six server-side items are delivered by a generated installer script |
55
+ | `cloudflare` backend | ✅ implemented as a tunnel mode (`--tunnel cloudflared`); ⚠️ not exercised in this environment |
56
+ | Multi-tenant gateway | ✅ implemented and verified against two real instances: per-tenant access key → per-tenant Harness process (`DSH_HOME`, port, launch token), panel card for add/remove/rotate/start/stop with a per-tenant link and QR, and `serve --multi` / `tenant` CLI. Isolation proof: one tenant's token against the other tenant's port returns 401 |
57
+ | `tailscale` backend | ✅ implemented as a tunnel mode (`--tunnel tailscale` / `public.tunnel: tailscale`): runs `tailscale funnel --bg` against the loopback port, reads the node's `ts.net` address from `tailscale status --json`, probes the funnel every 60s, and removes exactly its own mapping on stop. ⚠️ not exercised against a real tailnet in this environment (argv, URL parsing, start/stop and the failure path are unit-tested with a stub binary) |
58
+ | `setup-server` / `uninstall-server` | ✅ implemented: idempotent install/uninstall script (`probe` / `install` / `uninstall` / `--dry-run` / `--skip-*`) that only writes files it owns; the generated script passes `bash -n` and the generator rejects shell injection in its inputs |
59
+ | `doctor` | ✅ implemented: upstream provenance, proxy self-test, four public checks, live certificate expiry, `--expect-cert-sha256` cross-machine fingerprint comparison, local key-leak check |
60
+ | Port / token discovery (no hardcoded 3080 or 43129) | ✅ official `webServer.port` first; `/state` exposes `upstream.source` to prove it |
61
+ | Token acquisition (no log scraping) | ✅ official `connection.authenticatedUrl()`, resolved lazily; the log fallback accepts **only a start line whose port matches this process** |
62
+ | Credentials never persisted | ✅ `?k=` never reaches logs (the generated template redacts by default); no secrets in the repository |
63
+ | Config validation | ✅ exports `Config` as a zero-dependency Standard Schema: out-of-range ports, malformed domains, unknown `tunnel` values fail **before activation** |
64
+ | Bilingual panel | ✅ zh/en dictionaries (29 keys each) with `locale` as a soft dependency |
65
+ | Bilingual host-side text | ✅ one catalog (`lib/core/messages.js`, 87 keys per language) renders preflight results, tunnel state and panel API errors in the language the panel asks for (`?locale=`); `doctor`/`setup-server`/`keygen` detail output is still Chinese-only and prints an English notice (see CHANGELOG) |
66
+ | Certificate "is it actually served?" | ✅ two paths: the installer compares served vs on-disk live, and prints `--expect-cert-sha256` for `doctor` to verify from outside |
67
+
68
+ ---
69
+
70
+ ## Why it exists (and not just "open a port")
71
+
72
+ The Harness web server binds `127.0.0.1` only, so nothing else can reach it. Pointing a public domain straight at it also fails: the `/api` transport has a DNS-rebinding fence that trusts loopback or declared authorities only.
73
+
74
+ This plugin sits in between and does the three things that make it work:
75
+
76
+ 1. **Rewrites `Host`/`Origin`** to `127.0.0.1:<upstream port>` so the fence passes.
77
+ 2. **Injects `?token=` on first visit** (the process launch token) to mint the browser-session cookie — the token stays inside the process, never in URL history or logs.
78
+ 3. **Injects a same-origin stylesheet and a tiny shim** into the index so narrow screens work: the sidebar becomes an overlay drawer, the conversation keeps full width, tapping the scrim closes it, and safe-area insets apply.
79
+
80
+ Public mode adds an **access-key gate**: anything without a valid `?k=` gets a plain 404 (deliberately not 401, to avoid advertising that something is there).
81
+
82
+ ---
83
+
84
+ ## Install (DSH plugin)
85
+
86
+ ```bash
87
+ # 1) install into the profile directory (DSH resolves plugin names from there)
88
+ cd "$HOME/Library/Application Support/dsh-desktop/harness/profiles/web" # or ${DSH_HOME:-$HOME/.dsh}/profiles/web
89
+ npm install dsh-plugin-remote-connect-beta
90
+
91
+ # 2) add one entry to cordis.patch.yml in the same directory
92
+ # 3) restart DSH Desktop
93
+ ```
94
+
95
+ ```yaml
96
+ - insert:
97
+ - id: dsh-plugin-remote-connect-beta
98
+ name: dsh-plugin-remote-connect-beta
99
+ config:
100
+ lan:
101
+ enabled: true
102
+ port: 8787
103
+ public:
104
+ enabled: false
105
+ domain: dsh.example.com
106
+ port: 8788
107
+ tunnel: ssh # ssh | cloudflared | tailscale | none
108
+ tailscale: # only for tunnel: tailscale
109
+ path: tailscale # client binary
110
+ httpsPort: 443 # funnel's public HTTPS port (served by tailscaled)
111
+ probeMs: 60000 # funnel health probe interval
112
+ ssh:
113
+ user: dshtunnel
114
+ host: dsh.example.com
115
+ keyPath: ~/.ssh/dsh_remote_tunnel
116
+ port: 22022 # server sshd port; a non-22 port must be given
117
+ remotePort: 8788
118
+ ```
119
+
120
+ > Keep `id` equal to the package name (the shipped patches do the same), and do **not** also pass the same file with `--patch`: the profile's `cordis.patch.yml` is already loaded, and applying it twice fails with `duplicate loader entry id`.
121
+
122
+ Panel switches work **only in the host window** (loopback, not through a proxy); remote visitors get a read-only panel. That is deliberate — a remote visitor must not be able to change what you expose.
123
+
124
+ ---
125
+
126
+ ## Install (CLI only)
127
+
128
+ ```bash
129
+ npx dsh-plugin-remote-connect-beta serve # LAN entry; prints a QR code in the terminal
130
+ ```
131
+
132
+ Public entry with your own server:
133
+
134
+ ```bash
135
+ # 1) tunnel key pair + the restricted authorized_keys line to paste on the server
136
+ dsh-remote keygen
137
+
138
+ # 2) generate the server installer and review it before running anything
139
+ dsh-remote setup-server --domain dsh.example.com --ssh-user dshtunnel --out /tmp/dsh-setup.sh
140
+ scp /tmp/dsh-setup.sh <server>:/tmp/
141
+ ssh <server> "sudo bash /tmp/dsh-setup.sh probe" # probe only
142
+ ssh <server> "sudo bash /tmp/dsh-setup.sh install --dry-run" # print every step it would take
143
+ ssh <server> "sudo bash /tmp/dsh-setup.sh install" # idempotent install
144
+
145
+ # 3) start the proxy and the tunnel (reconnects with backoff)
146
+ dsh-remote serve --public --key "$(openssl rand -hex 16)" --domain dsh.example.com \
147
+ --tunnel ssh --ssh-user dshtunnel --ssh-host dsh.example.com --ssh-port 22022 \
148
+ --ssh-key ~/.ssh/dsh_remote_tunnel
149
+ # → public entry: https://dsh.example.com/?k=<key>
150
+
151
+ # 4) health check
152
+ dsh-remote doctor --domain dsh.example.com --user dsh --password '<edge password>' \
153
+ --ssh-user dshtunnel --ssh-host dsh.example.com --ssh-port 22022 --key '<access key>'
154
+
155
+ # remove it again
156
+ dsh-remote uninstall-server --domain dsh.example.com --out /tmp/dsh-uninstall.sh
157
+ ssh <server> "sudo bash /tmp/dsh-uninstall.sh"
158
+ ```
159
+
160
+ **No server at all?** Two zero-setup backends:
161
+
162
+ ```bash
163
+ # 1) tailscale funnel — your own machine is the exit; the node's ts.net name is the address
164
+ tailscale up # once, if you have not joined a tailnet
165
+ dsh-remote serve --public --key "$(openssl rand -hex 16)" --tunnel tailscale
166
+ # or in cordis.patch.yml: public: { enabled: true, tunnel: tailscale }
167
+ # the panel shows https://<node>.<tailnet>.ts.net/ once the funnel is up
168
+ # (Funnel must be enabled for the node in the Tailscale admin console; the plugin
169
+ # prints a hint if it is not)
170
+
171
+ # 2) cloudflared — temporary public address
172
+ dsh-remote serve --public --key "$(openssl rand -hex 16)" --tunnel cloudflared
173
+ ```
174
+
175
+ Both keep the access key gate: the public address is useless without `?k=<key>`, and the proxy still talks to the Harness over loopback only.
176
+
177
+ ---
178
+
179
+ ## Commands
180
+
181
+ | Command | Purpose |
182
+ | --- | --- |
183
+ | `serve` | Start the proxy; `--public` binds loopback only and enforces the key gate; `--tunnel ssh\|cloudflared\|tailscale` also starts a tunnel |
184
+ | `check` | DNS / certificate / edge password / ssh tunnel (or tailscale funnel), each with a verdict and a fix; text follows `--lang` / `DSH_REMOTE_LANG` / `LANG` |
185
+ | `doctor` | Full physical: upstream provenance, proxy self-test, the four public checks, **the certificate actually served**, `--expect-cert-sha256` comparison, plus the checks only the server can run |
186
+ | `snippets` | Print nginx / Caddy fragments and the `authorized_keys` line (for people who prefer to hand-write config) |
187
+ | `keygen` | Generate the tunnel ed25519 key pair (default `~/.ssh/dsh_remote_tunnel`) |
188
+ | `setup-server` | Generate the server installer (prints by default; `probe` / `install` / `--dry-run` inside) |
189
+ | `uninstall-server` | Generate a standalone uninstall script (`--purge-user` also removes the account) |
190
+
191
+ Common flags: `--port`, `--upstream`, `--token`, `--domain` (repeatable), `--no-mobile`, `--json`; tunnel flags: `--ssh-user`, `--ssh-host`, `--ssh-key`, **`--ssh-port`**, `--remote-port`.
192
+
193
+ ---
194
+
195
+ ## Security model
196
+
197
+ The public entry point fronts a machine that can execute commands, so the gates are real:
198
+
199
+ - **Edge password** (nginx `auth_basic` / Caddy `basic_auth` / Cloudflare Access): keeps out anyone who merely knows the address.
200
+ - **Access key `?k=`** (this plugin, on by default): if the edge configuration is ever loosened or bypassed, the attacker still gets a 404. Rotating the key invalidates old links immediately.
201
+ - **Harness session token**: injected only for requests that passed the first two gates.
202
+
203
+ Where each backend sits on that ladder:
204
+
205
+ | Backend | Who can reach it | Gates in front of it |
206
+ | --- | --- | --- |
207
+ | `lan` | Anything on the same network | Harness session only — **no access key by design**, because the LAN entry exists so a phone can open the address with no ceremony. On an untrusted network (campus, hotel, office guest Wi-Fi), use the public backend instead |
208
+ | `selfhost` | The internet | edge password + `?k=` + Harness session |
209
+ | `cloudflared` | The (temporary) internet address | `?k=` + Harness session — add Cloudflare Access if you keep it |
210
+ | `tailscale` | Your tailnet (and, with Funnel, the public internet) | `?k=` + Harness session; Tailscale ACLs if you keep it tailnet-only |
211
+
212
+ Hygiene rules the code enforces:
213
+
214
+ - `serve --public` refuses to start without `--key` (unless you explicitly pass `--allow-no-key`, which is not recommended).
215
+ - The generated server template logs a **redacted** request line (`$uri`, no query), so `?k=` never lands on disk; `doctor` can check the server log and local logs for leaks.
216
+ - Harness itself always binds loopback; the plugin never offers a "bind 0.0.0.0" option for the upstream.
217
+ - **Tenant isolation is per Harness process.** One Harness instance serves exactly one person: its sessions, credentials, settings, workspace and launch token all live in that instance's own `DSH_HOME`. So multi-tenancy is not a flag on a single instance — it is the gateway routing each tenant to **their own** instance. That layer (per-tenant credentials → per-tenant upstream + per-tenant token, instances started and supervised by this plugin) is being built now; the underlying mechanism is already verified: two instances on one machine boot with separate `DSH_HOME`s on separate loopback ports, each prints its own launch token, and using one tenant's token against the other tenant's port returns **401**.
218
+
219
+ ---
220
+
221
+ ## Known limits
222
+
223
+ | Limit | Detail |
224
+ | --- | --- |
225
+ | Browser floor | The Harness frontend uses `Promise.withResolvers`, so the practical floor is roughly **Chrome/Edge 119+, Safari 17.4+, Firefox 121+**. Older browsers show a blank page; that is the frontend, not this plugin |
226
+ | Corporate/campus proxies | If a proxy does not pass WebSocket `Upgrade` (common with TLS interception), the live channel breaks and the UI reports a disconnected session. Change networks or tunnel egress |
227
+ | Proxy authentication (407) | Stacked on top of Basic Auth, some browsers handle the double prompt poorly |
228
+ | Latency | Streaming output crosses the tunnel twice; a distant VPS adds roughly 50–200 ms per turn |
229
+ | sshd port | Servers often move ssh off 22. Configure `public.ssh.port` / pass `--ssh-port`, or the tunnel fails with `Connection refused` |
230
+ | Access logs | Any log that records the full URI leaks the `?k=` gate. The generated template redacts by default; verify with `doctor` |
231
+ | Port 80 | The template deliberately emits **no** port-80 server block: an exact `server_name` block there would shadow `/.well-known/acme-challenge/` and break issuance/renewal |
232
+ | Sidebar width | Harness sizes the collapsed rail from the user agent (80 px on Mac, 56 px on phones). Do not force it narrower in CSS — the icons get clipped |
233
+
234
+ ---
235
+
236
+ ## Layout
237
+
238
+ ```
239
+ bin/dsh-remote.js CLI entry (serve / check / doctor / snippets / keygen / setup-server / uninstall-server)
240
+ lib/index.js DSH plugin host half: routes, in-process proxy and tunnel, lifecycle
241
+ lib/client.js DSH plugin client half: hand-written bundle (no build step), sidebar entry + panel
242
+ lib/core/proxy.js Reverse proxy core: Host rewrite, token injection, mobile adaptation, key gate
243
+ lib/core/tunnel.js ssh -R / cloudflared supervision with exponential backoff; tailscale funnel start/stop
244
+ lib/core/tailscale.js tailscale funnel argv, status parsing and error classification (pure + testable)
245
+ lib/core/messages.js zh/en catalog for host-generated text (preflight, tunnel state, API errors, CLI)
246
+ lib/core/preflight.js DNS / TLS / HTTPS+auth / ssh tunnel checks
247
+ lib/core/snippets.js nginx / Caddy / authorized_keys generation
248
+ lib/core/serversetup.js Server installer generation (input validation against shell injection)
249
+ lib/core/assets/ Installer script template (real bash; output must pass `bash -n`)
250
+ test/verify.mjs Contract / render / generator assertions
251
+ test/e2e-isolated.sh End-to-end: boot an isolated DSH instance and mount this repo as a plugin
252
+ test/no-private-values.sh Gate: no author-private values in the repository
253
+ ```
254
+
255
+ ---
256
+
257
+ ## Verification
258
+
259
+ ```bash
260
+ npm test # contract, render, generator and localization assertions
261
+ npm run gate # no author-private values in the repository
262
+ npm run e2e # isolated DSH instance, plugin mounted, entry reachable
263
+ ```
264
+
265
+ `npm test` covers the host half (export shape, config validation, routes, switches, privilege fence, upstream provenance, port release after fiber disposal), the client half (`__ModuleLoader__.load` protocol, slot registration, zh/en dictionaries, store/fetch interaction, real React SSR including the QR SVG), and the generators (the rendered installer and uninstaller must pass `bash -n`; shell injection in inputs must be rejected).
266
+
267
+ `test/e2e-isolated.sh` boots a **real Harness** with its own `DSH_HOME` and port, mounts this repository as a plugin, and asserts: no `plugin failures`, host API reachable, LAN entry opened by the plugin, client half present in the boot graph, and the LAN entry completing the token exchange.
268
+
269
+ See `docs/client-preview.png` for the client half rendered in a real browser with sanitized data.
270
+
271
+ ---
272
+
273
+ ## Contributing
274
+
275
+ See [CONTRIBUTING.md](CONTRIBUTING.md) — including the pre-publish checklist (repository metadata, npm scope, changelog) that a fork owner must fill in.
276
+
277
+ ## License
278
+
279
+ MIT — see [LICENSE](LICENSE). Security policy: [SECURITY.md](SECURITY.md).