dsh-simple-remote 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Lam
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.en.md ADDED
@@ -0,0 +1,133 @@
1
+ # dsh-simple-remote
2
+
3
+ [中文](README.md) | English
4
+
5
+ Reach DSH from your phone with a single tool. Tailscale alone builds the network: any device with it installed
6
+ joins the tailnet and can reach the others.
7
+ This plugin opens the DeepSeek Harness Web GUI (the GUI of `dsh --profile web`) **only** to your own Tailscale
8
+ tailnet: it starts a second listening port on the Tailscale interface address and reverse proxies it to the GUI
9
+ on loopback. A phone that is on the tailnet simply opens the page and it works, while other devices on the LAN,
10
+ in WSL, or on a virtual switch cannot even see the port.
11
+
12
+ ## What it does
13
+
14
+ ```
15
+ phone (Tailscale) ──► 100.x.y.z:3080 ┐
16
+ │ dsh-simple-remote reverse proxy (rewrites Host/Origin to loopback)
17
+ local browser ──► 127.0.0.1:3080 ─┴─► dsh's own Web server (still bound to loopback only)
18
+ ```
19
+
20
+ - It does **not** change the bind of dsh's own Web server: that server still listens on `127.0.0.1` only, so neither
21
+ the LAN nor the virtual adapters are exposed.
22
+ - It does **not** loosen the shipped Host/Origin fence: the proxy rewrites the forwarded Host to the loopback
23
+ address, so no new "trusted authority" is added. `Origin` is rewritten only when it was same-origin with the
24
+ request that arrived, and a cross-site request keeps its own Origin and is refused (403) by the fence.
25
+ - It **keeps** the shipped authentication: session cookies and `/api` token checks are unchanged; the proxy merely
26
+ performs the first login for you (`autoToken`).
27
+ - WebSocket (terminal, remote streams) and SSE event streams pass through untouched.
28
+
29
+ ## Install
30
+
31
+ The plugin is a local bundle (this directory is the package). Install it with the plugin manager inside the Harness:
32
+
33
+ ```
34
+ plugin_manager { "action": "install_bundle", "target": "C:\\Users\\ivan\\Documents\\projects\\dsh-simple-remote" }
35
+ ```
36
+
37
+ Or install this directory from the "Plugins" page of the Web GUI. After installing:
38
+
39
+ - A new Loader row `simple-remote` appears (package name `dsh-simple-remote`).
40
+ - One line shows up in the startup log:
41
+ `dsh web: tailnet http://100.111.128.100:3080 (Tailscale; auto-login)`
42
+ - Settings → General gains a "Phone access (Tailscale)" row that shows the address and copies it in one click.
43
+
44
+ On the **phone**: connect to Tailscale and open `http://<your tailnet address>:3080/` directly.
45
+ The plugin completes the login on the first visit (see below), and the browser then remembers it for 30 days.
46
+
47
+ ## Configuration
48
+
49
+ Row config lives in `cordis.patch.yml` (after installing it is
50
+ `~/.dsh/profiles/web/node_modules/dsh-simple-remote/cordis.patch.yml`; you can also override it from the profile's
51
+ own patch layer — note that a patch replaces the whole `config`, so every key you want to keep must be restated):
52
+
53
+ ```yaml
54
+ - id: simple-remote
55
+ name: dsh-simple-remote
56
+ config:
57
+ enabled: true # false = off entirely, no tailnet port is opened
58
+ autoToken: true # see "Security"
59
+ address: '' # empty = detect the Tailscale address (100.64.0.0/10); or pin an address or MagicDNS name
60
+ port: 0 # 0 = mirror the loopback GUI's port (3080 by default)
61
+ log: true # also print the address line to the dsh process output
62
+ ```
63
+
64
+ ## Security
65
+
66
+ `autoToken: true` (the default) means: **anyone who can connect to this port has full control of this machine**
67
+ (arbitrary command execution). The boundary therefore rests entirely on Tailscale — only your own tailnet devices
68
+ can connect, and Tailscale's ACLs and device authorization are that door. On a shared tailnet (a team, a family),
69
+ set `autoToken: false`:
70
+
71
+ ```yaml
72
+ - id: simple-remote
73
+ name: dsh-simple-remote
74
+ config:
75
+ autoToken: false
76
+ ```
77
+
78
+ The phone must then open the full `?token=…` link (copy it from the startup log or the settings row), and its
79
+ behaviour is exactly that of the loopback address; a tailnet device that does not know the token only gets a 401.
80
+
81
+ In either mode the port is bound to the Tailscale address alone — `0.0.0.0` is never used — so devices on the LAN
82
+ or a public network cannot reach it. That is also the red line behind dsh's official refusal of `--host 0.0.0.0`.
83
+
84
+ ## Verification
85
+
86
+ `node test/smoke.mjs` runs the plugin itself outside the Harness (13 checks): the browser half's factory and
87
+ rendering (auto-login, token and disabled states, plus the copy button), the proxy's token redirect, cookie
88
+ pass-through, `/api` Host/Origin rewriting, a cross-site Origin left untouched, the WebSocket upgrade, page-state
89
+ injection, and the listener closing on disposal.
90
+
91
+ It was also exercised against a live Harness (reached from this machine through the Tailscale address):
92
+
93
+ | Check | Result |
94
+ | --- | --- |
95
+ | Listening addresses | `100.111.128.100:3080` (plugin) + `127.0.0.1:3080` (dsh's own, unchanged) |
96
+ | Navigation without a cookie (`Sec-Fetch-Mode: navigate`) | 200, the 35 KB index page, and a `dsh-auth-…` session cookie |
97
+ | `POST /api/<unknown endpoint>` (with cookie) | 404 — the Host/Origin fence and authentication both passed |
98
+ | Same, without a cookie | 401 |
99
+ | Same, with `Origin: https://evil.example` | 403 |
100
+ | Plugin asset `plugins/??dsh-simple-remote/client.js` | 200 |
101
+ | WebSocket `GET /api/remote.mux` (with / without cookie) | 101 / 401 |
102
+ | Loopback `http://127.0.0.1:3080/` (no cookie) | 401, as before the plugin was installed |
103
+
104
+ Only one item could not be verified on this machine: **a real connection from another device (a phone) over the
105
+ network**, because the local firewall rules require administrator rights to inspect. If the phone times out, see the
106
+ troubleshooting table below.
107
+
108
+ ## Troubleshooting
109
+
110
+ | Symptom | What to do |
111
+ | --- | --- |
112
+ | The phone cannot connect, and this machine cannot reach the address either | Check the dsh log for `simple-remote: ... listener failed`; confirm Tailscale is connected and this machine has a `100.x.y.z` address |
113
+ | The log says "no Tailscale address found" | Tailscale is not running, or the address is outside `100.64.0.0/10`; pin it with the `address` config |
114
+ | This machine works, the phone times out | Windows Firewall is blocking inbound. In an administrator PowerShell (replace `<node.exe path>` with the output of `where.exe node`): `New-NetFirewallRule -DisplayName "DSH Tailscale Remote" -Direction Inbound -Program "<node.exe path>" -Action Allow -Profile Any`; or allow just the port: `New-NetFirewallRule -DisplayName "DSH Web Tailscale" -Direction Inbound -Protocol TCP -LocalPort 3080 -Action Allow` |
115
+ | The phone shows 401 | `autoToken` is off; open the link with `?token=` once |
116
+ | The page opens on the phone but API calls return 403 | You are using a different host name or address (for example a renaming reverse proxy); add `--trusted-host` or use the address the plugin prints |
117
+ | You want another port | Change `port`, or run `dsh --profile web --port 8080` (the plugin follows the loopback port by default) |
118
+
119
+ ## Picking up code changes
120
+
121
+ - `client.js` (the settings row): just refresh the page — the browser re-fetches plugin assets.
122
+ - `index.js` (the proxy half): ESM modules are cached, so **dsh must be restarted** (or the bundle disabled and
123
+ re-enabled on the Plugins page) before the new code loads. Run `node test/smoke.mjs` before changing it, and do not
124
+ feed broken code to a running server.
125
+
126
+ ## Known limits
127
+
128
+ - Only IPv4 tailnet addresses are supported (Tailscale always assigns one in `100.64.0.0/10`).
129
+ - The phone gets plain HTTP, which is not a secure context (Tailscale HTTPS certificates need another mechanism,
130
+ such as `tailscale serve`). The features tested here are unaffected, but browser APIs that require a secure
131
+ context (for example Service Workers) are unavailable.
132
+ - The address is detected once at startup; after Tailscale changes it, dsh must be restarted (or the plugin row
133
+ reloaded).
package/README.md CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # dsh-simple-remote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [English](README.en.md) | 中文
4
+
5
+ 只用一个插件就可以手机也访问到DSH。安装即用,不用配置。
6
+
7
+ 只使用Tailscale,安装了这个软件的设备就组网了,可以互相访问。
8
+ 把 DeepSeek Harness 的网页界面(`dsh --profile web` 的 GUI)**只**开放给本机的 Tailscale tailnet:
9
+ 在 Tailscale 网卡地址上另开一个监听端口,反向代理到本机回环上的 GUI。
10
+ 于是「连上 Tailscale 的手机」直接打开网页就能用,而局域网、WSL、虚拟网卡上的其他设备连端口都看不到。
11
+
12
+ ## 它做什么
13
+
14
+ ```
15
+ 手机 (Tailscale) ──► 100.x.y.z:3080 ┐
16
+ │ dsh-simple-remote 反向代理(改写 Host/Origin 为回环)
17
+ 本机浏览器 ──► 127.0.0.1:3080 ─┴─► dsh 自带的 Web 服务(保持只监听回环)
18
+ ```
19
+
20
+ - **不改变** dsh 自带 Web 服务的绑定:它仍然只监听 `127.0.0.1`,LAN 与虚拟网卡都不会被暴露。
21
+ - **不放宽**自带的 Host/Origin 防护栅栏:代理把转发请求的 Host 改写为回环地址,因此没有新增任何「受信域名」;
22
+ `Origin` 仅在它与收到的请求同源时才改写,跨站请求保留原 Origin 并被栅栏拒绝(403)。
23
+ - **保留**自带认证:会话 cookie、`/api` 令牌校验全部照旧,只是由代理代为完成首次登录(`autoToken`)。
24
+ - WebSocket(终端、远程流)与 SSE 事件流按原样透传。
25
+
26
+ ## 安装
27
+
28
+ npm(默认)
29
+
30
+ ```
31
+ dsh plugin --profile web add @ivanant/dsh-simple-remote@latest
32
+ ```
33
+
34
+ Github方式
35
+
36
+ ```
37
+ dsh plugin --profile web add github:ivanant/dsh-simple-remote
38
+ ```
39
+
40
+ 或在 Web 界面「插件」页里安装这个包名 `dsh-simple-remote`。
41
+
42
+ 安装后:
43
+
44
+ - 新增 Loader 行 `simple-remote`(包名 `dsh-simple-remote`)。
45
+ - 启动日志里会出现一行:
46
+ `dsh web: tailnet http://100.111.128.100:3080 (Tailscale; auto-login)`
47
+ - 设置 → 通用 里多出一行「手机访问(Tailscale)」,显示地址并可一键复制。
48
+
49
+ 装完后**手机端**:连上 Tailscale,直接打开 `http://<你的 tailnet 地址>:3080/` 即可。
50
+ 首次访问时插件自动完成登录(见下),浏览器随后记住 30 天。
51
+
52
+ ## 配置
53
+
54
+ 安装后直接使用就行。特殊情况可以配置:
55
+
56
+ 行配置写在 `cordis.patch.yml` 里(安装后是 `~/.dsh/profiles/web/node_modules/dsh-simple-remote/cordis.patch.yml`,
57
+ 也可以用 profile 自己的 patch 层覆盖,注意 patch 是整体替换 `config`,要保留的键都得重写):
58
+
59
+ ```yaml
60
+ - id: simple-remote
61
+ name: dsh-simple-remote
62
+ config:
63
+ enabled: true # false = 完全关闭,不监听任何 tailnet 端口
64
+ autoToken: true # 见「安全」一节
65
+ address: "" # 留空 = 自动探测 Tailscale 地址(100.64.0.0/10);也可写死地址或 MagicDNS 名
66
+ port: 0 # 0 = 与回环 GUI 同端口(默认 3080)
67
+ log: true # 是否把地址行同时打印到 dsh 进程输出
68
+ ```
69
+
70
+ ## 安全
71
+
72
+ `autoToken: true`(默认)意味着:**能连上这个端口的人,就等于拿到了这台机器的完全控制权**(可执行任意命令)。
73
+ 边界因此完全落在 Tailscale 上——只有你自己的 tailnet 设备能连上,Tailscale 的 ACL / 设备授权就是这道门。
74
+ 如果你在共享 tailnet(团队、家人)里,建议改成 `autoToken: false`:
75
+
76
+ ```yaml
77
+ - id: simple-remote
78
+ name: dsh-simple-remote
79
+ config:
80
+ autoToken: false
81
+ ```
82
+
83
+ 此时手机必须打开带 `?token=…` 的完整链接(启动日志 / 设置页可复制),行为与回环地址完全一致;
84
+ 不知道令牌的 tailnet 设备只会收到 401。
85
+
86
+ 无论哪种模式,端口都只绑在 Tailscale 地址上,`0.0.0.0` 从未被使用,
87
+ 所以局域网 / 公共网络里的设备无法访问(这也是 dsh 官方拒绝 `--host 0.0.0.0` 的那条红线)。
88
+
89
+ ## 验证
90
+
91
+ `node test/smoke.mjs` 会在 Harness 之外跑一遍插件本体(共 13 项):浏览器半段的工厂与渲染(自动登录 / 令牌 / 关闭三种状态 + 复制按钮)、
92
+ 代理的令牌跳转、cookie 透传、`/api` 的 Host/Origin 改写、跨站 Origin 保持原样、WebSocket 升级、页面状态注入、以及卸载时关闭监听。
93
+
94
+ 装到活的 Harness 之后也实测过(本机经 Tailscale 地址访问):
95
+
96
+ | 检查 | 结果 |
97
+ | --------------------------------------------------- | -------------------------------------------------------------------- |
98
+ | 监听地址 | `100.111.128.100:3080`(插件)+ `127.0.0.1:3080`(dsh 自带,未改动) |
99
+ | 无 cookie 的导航(带 `Sec-Fetch-Mode: navigate`) | 200,返回 35 KB 首页,并拿到 `dsh-auth-…` 会话 cookie |
100
+ | `POST /api/<未知端点>`(带 cookie) | 404 —— 说明 Host/Origin 栅栏与认证都通过了 |
101
+ | 同上但无 cookie | 401 |
102
+ | 同上但 `Origin: https://evil.example` | 403 |
103
+ | 插件资源 `plugins/??dsh-simple-remote/client.js` | 200 |
104
+ | WebSocket `GET /api/remote.mux`(带 cookie / 不带) | 101 / 401 |
105
+ | 回环地址 `http://127.0.0.1:3080/`(无 cookie) | 401,与装插件前一致 |
106
+
107
+ 未能在此机器上验证的只有一项:**另一台设备(手机)经网络真实连入**——本机防火墙规则需要管理员权限才能查看。
108
+ 若手机超时,见下面的排错表。
109
+
110
+ ## 排错
111
+
112
+ | 现象 | 处理 |
113
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
114
+ | 手机连不上、本机也访问不了该地址 | 看 dsh 日志里有没有 `simple-remote: ... listener failed`;确认 Tailscale 已连接且本机有 `100.x.y.z` 地址 |
115
+ | 日志里出现「no Tailscale address found」 | Tailscale 未运行,或地址不在 `100.64.0.0/10`;用 `address` 配置写死地址 |
116
+ | 本机可访问、手机超时 | Windows 防火墙拦了入站。管理员 PowerShell 里执行(把 `<node.exe 路径>` 换成 `where.exe node` 的结果):`New-NetFirewallRule -DisplayName "DSH Tailscale Remote" -Direction Inbound -Program "<node.exe 路径>" -Action Allow -Profile Any`;或只放行端口:`New-NetFirewallRule -DisplayName "DSH Web Tailscale" -Direction Inbound -Protocol TCP -LocalPort 3080 -Action Allow` |
117
+ | 手机打开是 401 | `autoToken` 被关掉了;用带 `?token=` 的链接打开一次 |
118
+ | 手机页面能开但接口 403 | 你在用别的域名/地址访问(例如反向代理改名);请加 `--trusted-host` 或改用插件打印的地址 |
119
+ | 想换端口 | 改 `port`,或用 `dsh --profile web --port 8080`(回环换端口后插件默认跟随) |
120
+
121
+ ## 改代码后如何生效
122
+
123
+ - `client.js`(设置页那一行):改完刷新页面即可,浏览器会重新拉取插件资源。
124
+ - `index.js`(代理那一半):ESM 模块有缓存,**必须重启 dsh**(或在插件页里禁用再启用该 bundle)才会加载新代码;
125
+ 所以改动前先跑 `node test/smoke.mjs` 自检,别把坏代码喂给正在跑的服务。
126
+
127
+ ## 已知边界
128
+
129
+ - 只支持 IPv4 的 tailnet 地址(Tailscale 一定会分配一个 `100.64.0.0/10` 地址)。
130
+ - 手机上是普通 HTTP,不是安全上下文(Tailscale 的 HTTPS 证书需要另一套机制,例如 `tailscale serve`);
131
+ 测过的功能不受影响,但浏览器 API 里要求安全上下文的能力(如 Service Worker)不可用。
132
+ - 只在启动时探测一次地址;Tailscale 换地址后需要重启 dsh(或重载该插件行)。
package/client.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Browser half of dsh-simple-remote: the General-settings row that shows the
3
+ * tailnet address of this harness and copies it.
4
+ *
5
+ * The host half injects `globalThis.__DSH_SIMPLE_REMOTE__` into the page
6
+ * (`webserver/index-inject`), so the row needs no RPC: it renders nothing when
7
+ * that state is absent, which is the case for every deployment without a
8
+ * Tailscale address.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-simple-remote',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const h = React.createElement
15
+ const NS = 'simple-remote'
16
+
17
+ const zh = {
18
+ title: '手机访问(Tailscale)',
19
+ autoDesc: '同一 tailnet 内的手机可直接打开下面的地址,登录由插件自动完成。',
20
+ tokenDesc: '请在手机上打开下面这条带令牌的链接;登录后浏览器会记住 30 天。',
21
+ copy: '复制',
22
+ copied: '已复制',
23
+ copyToken: '复制带令牌的链接',
24
+ }
25
+ const en = {
26
+ title: 'Mobile access (Tailscale)',
27
+ autoDesc: 'A phone on the same tailnet can open the address below; the plugin signs it in automatically.',
28
+ tokenDesc: 'Open the tokenized link below on the phone; the browser then remembers it for 30 days.',
29
+ copy: 'Copy',
30
+ copied: 'Copied',
31
+ copyToken: 'Copy tokenized link',
32
+ }
33
+
34
+ const CSS_ID = 'dsh-simple-remote/RemoteAccessRow.css'
35
+ const css = [
36
+ '.dsr-row{border-bottom:.5px solid var(--dsw-alias-border-l2);padding:16px 0}',
37
+ '.dsr-title{font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary)}',
38
+ '.dsr-desc{color:var(--dsw-alias-label-secondary);margin-top:4px;font-size:12px;line-height:18px}',
39
+ '.dsr-line{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}',
40
+ '.dsr-url{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:4px 8px;color:var(--dsw-alias-label-primary);word-break:break-all}',
41
+ '.dsr-button{font-size:12px;line-height:18px;padding:4px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit}',
42
+ '.dsr-button:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
43
+ '.dsr-secondary{margin-top:8px}',
44
+ ].join('')
45
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']') === null) {
46
+ const tag = document.createElement('style')
47
+ tag.dataset.plugin = 'dsh-simple-remote'
48
+ tag.dataset.pluginCss = CSS_ID
49
+ tag.textContent = css
50
+ document.head.appendChild(tag)
51
+ }
52
+
53
+ /** Copy through the async clipboard, falling back to the selection command. */
54
+ function copyText(text) {
55
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
56
+ if (clipboard !== undefined && typeof clipboard.writeText === 'function') return clipboard.writeText(text)
57
+ return new Promise((resolve, reject) => {
58
+ try {
59
+ const area = document.createElement('textarea')
60
+ area.value = text
61
+ area.setAttribute('readonly', '')
62
+ area.style.position = 'fixed'
63
+ area.style.top = '-1000px'
64
+ area.style.opacity = '0'
65
+ document.body.appendChild(area)
66
+ area.select()
67
+ const copied = document.execCommand('copy')
68
+ document.body.removeChild(area)
69
+ if (copied) resolve()
70
+ else reject(new Error('copy refused'))
71
+ } catch (error) {
72
+ reject(error)
73
+ }
74
+ })
75
+ }
76
+
77
+ /** One General-settings row: the tailnet address and its copy control. */
78
+ function RemoteAccessRow(props) {
79
+ const t = typeof props.t === 'function' ? props.t : (key) => en[key] ?? key
80
+ const state = globalThis.__DSH_SIMPLE_REMOTE__
81
+ const [copied, setCopied] = React.useState('')
82
+ const timer = React.useRef(0)
83
+ React.useEffect(() => () => {
84
+ if (timer.current !== 0) clearTimeout(timer.current)
85
+ }, [])
86
+ if (state === null || state === undefined || state.enabled !== true) return null
87
+ const urls = Array.isArray(state.urls) ? state.urls : []
88
+ if (urls.length === 0) return null
89
+ const auto = state.autoToken === true
90
+ const primary = urls[0]
91
+ const tokenUrl = typeof state.loginUrl === 'string' ? state.loginUrl : primary
92
+ const copy = (value, which) => {
93
+ copyText(value).then(() => {
94
+ setCopied(which)
95
+ if (timer.current !== 0) clearTimeout(timer.current)
96
+ timer.current = setTimeout(() => setCopied(''), 1600)
97
+ }).catch(() => {})
98
+ }
99
+ const rows = [
100
+ h('div', { className: 'dsr-title', key: 'title' }, t('title')),
101
+ h('div', { className: 'dsr-desc', key: 'desc' }, auto ? t('autoDesc') : t('tokenDesc')),
102
+ h('div', { className: 'dsr-line', key: 'line' }, [
103
+ h('code', { className: 'dsr-url', key: 'url' }, auto ? primary : tokenUrl),
104
+ h('button', {
105
+ className: 'dsr-button',
106
+ type: 'button',
107
+ key: 'copy',
108
+ onClick: () => copy(auto ? primary : tokenUrl, 'main'),
109
+ }, copied === 'main' ? t('copied') : t('copy')),
110
+ ]),
111
+ ]
112
+ if (!auto) {
113
+ rows.push(h('button', {
114
+ className: 'dsr-button dsr-secondary',
115
+ type: 'button',
116
+ key: 'token',
117
+ onClick: () => copy(tokenUrl, 'token'),
118
+ }, copied === 'token' ? t('copied') : t('copyToken')))
119
+ }
120
+ return h('div', { className: 'dsr-row' }, rows)
121
+ }
122
+
123
+ return {
124
+ inject: ['slots', 'locale'],
125
+ apply(ctx) {
126
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'simple-remote: dictionaries')
127
+ ctx.slots.inject('settings.general.item', () => ctx.slots.register({
128
+ name: 'settings.general.item',
129
+ id: 'simple-remote',
130
+ order: 95,
131
+ locale: NS,
132
+ }, RemoteAccessRow))
133
+ },
134
+ }
135
+ },
136
+ })
@@ -0,0 +1,30 @@
1
+ # The dsh-simple-remote bundle patch: a tailnet-only listener beside the
2
+ # loopback Web GUI, plus the settings row that shows its address.
3
+ #
4
+ # `enabled` turns the whole bridge off; `autoToken` completes the login for a
5
+ # phone navigation; `address` overrides Tailscale detection; `port` mirrors the
6
+ # loopback GUI's port when it is 0.
7
+ #
8
+ # A patch replaces the targeted row's whole `config`, never merges it, so an
9
+ # override in the profile's own cordis.patch.yml restates every key it keeps:
10
+ #
11
+ # - id: simple-remote
12
+ # name: dsh-simple-remote
13
+ # config:
14
+ # autoToken: false
15
+
16
+ - insert:
17
+ - id: simple-remote
18
+ name: dsh-simple-remote
19
+ config:
20
+ enabled: true
21
+ # On: a phone on the tailnet opens the plain address and is signed in.
22
+ # Off: the printed `?token=…` URL is required, as on loopback.
23
+ autoToken: true
24
+ # Empty: detect the Tailscale address. Set one to pin a MagicDNS name
25
+ # or an address the interface scan does not see.
26
+ address: ''
27
+ # 0: mirror the loopback GUI's port.
28
+ port: 0
29
+ # Mirror the URL lines to the dsh process output.
30
+ log: true
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Tailscale remote access">
2
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="#1f6feb"/>
3
+ <g fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4
+ <circle cx="20" cy="42" r="6"/>
5
+ <circle cx="44" cy="22" r="6"/>
6
+ <path d="M25 38 L39 26"/>
7
+ <path d="M14 30 A18 18 0 0 1 30 14"/>
8
+ <path d="M50 34 A18 18 0 0 1 34 50"/>
9
+ </g>
10
+ <circle cx="52" cy="12" r="4" fill="#ffffff"/>
11
+ </svg>
package/index.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * dsh-simple-remote — serve the DeepSeek Harness Web GUI to a Tailscale tailnet.
3
+ *
4
+ * The shipped Web composition binds `@deepseek-ai/dsh-host-webserver` to a
5
+ * loopback-only host (`host` accepts exactly `127.0.0.1` or `0.0.0.0`), and its
6
+ * `/api` browser-trust fence admits loopback or declared authorities. Neither
7
+ * expresses "reachable by my own tailnet and by nothing else", so this plugin
8
+ * adds that missing posture without weakening either one:
9
+ *
10
+ * - A second listener, bound to the machine's Tailscale address only, reverse
11
+ * proxies every request to the loopback GUI. The LAN, WSL and virtual-switch
12
+ * addresses never listen, so nothing off the tailnet can even open a socket.
13
+ * - Forwarded requests keep their path, body, cookies and streaming semantics.
14
+ * The proxy rewrites the upstream authority to the loopback GUI, which keeps
15
+ * the shipped Host/Origin fence exactly as strict as before (no authority is
16
+ * granted), and rewrites `Origin` only when it was same-origin with the
17
+ * request it received — a cross-site request keeps its foreign Origin and is
18
+ * refused by the fence.
19
+ * - `autoToken` (on by default) completes the launch-token exchange for a
20
+ * browser navigation that has no session cookie yet, so a phone on the
21
+ * tailnet can open the plain `http://<tailnet-address>:<port>/` address.
22
+ * With `autoToken: false` the printed `?token=…` URL is required, and the
23
+ * tailnet behaves exactly like the loopback address does.
24
+ *
25
+ * @module dsh-simple-remote
26
+ */
27
+
28
+ import { createHash } from 'node:crypto'
29
+ import { createServer, request as httpRequest } from 'node:http'
30
+ import { connect as netConnect } from 'node:net'
31
+ import { networkInterfaces } from 'node:os'
32
+
33
+ /** Stable Cordis plugin name. */
34
+ export const name = 'simple-remote'
35
+
36
+ /** The listener needs the composed Web server (its port and its loopback bind). */
37
+ export const inject = ['webServer']
38
+
39
+ /** The loopback literal the shipped Web composition binds. */
40
+ const LOOPBACK = '127.0.0.1'
41
+ /** Tailscale's CGNAT range, 100.64.0.0/10: the addresses the tailnet hands out. */
42
+ const TAILNET_V4 = /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./
43
+ /** Interface-name hint for tailnets whose addresses are not in the CGNAT range. */
44
+ const TAILNET_INTERFACE = /^tailscale/i
45
+ /** Browser-session cookie prefix owned by `@deepseek-ai/dsh-client-connection`. */
46
+ const COOKIE_PREFIX = 'dsh-auth-'
47
+ /** Request headers that describe one hop and must not be forwarded verbatim. */
48
+ const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'expect']
49
+
50
+ /**
51
+ * Resolve the row config, applying defaults without a schema dependency.
52
+ * @param config - the raw row config from the bundle patch.
53
+ * @returns normalized options.
54
+ */
55
+ function readOptions(config) {
56
+ const source = config ?? {}
57
+ return {
58
+ enabled: source.enabled !== false,
59
+ /** Explicit tailnet address; empty means detect from the interfaces. */
60
+ address: typeof source.address === 'string' ? source.address.trim() : '',
61
+ /** Tailnet port; 0 (the default) mirrors the loopback GUI's port. */
62
+ port: Number.isInteger(source.port) && source.port >= 0 && source.port <= 65535 ? source.port : 0,
63
+ /** Complete the launch-token exchange for a cookieless browser navigation. */
64
+ autoToken: source.autoToken !== false,
65
+ /** Mirror the plugin's URL lines to stdout. */
66
+ log: source.log !== false,
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Tailnet IPv4 addresses of this machine, in interface order.
72
+ * @param explicit - a configured address, which replaces detection entirely.
73
+ * @returns the addresses a listener should bind, deduplicated.
74
+ */
75
+ function detectAddresses(explicit) {
76
+ if (explicit !== '') return [explicit]
77
+ const found = []
78
+ for (const [iface, list] of Object.entries(networkInterfaces())) {
79
+ for (const info of list ?? []) {
80
+ if (info.family !== 'IPv4' || info.internal) continue
81
+ if (TAILNET_V4.test(info.address) || TAILNET_INTERFACE.test(iface)) found.push(info.address)
82
+ }
83
+ }
84
+ return [...new Set(found)]
85
+ }
86
+
87
+ /** Bracket an IPv6 literal so it composes into a URL authority. */
88
+ function formatHost(address) {
89
+ return address.includes(':') ? `[${address}]` : address
90
+ }
91
+
92
+ /** The `dsh-auth-…` cookie name this deployment's loopback authority mints. */
93
+ function sessionCookieName(upstreamPort) {
94
+ const digest = createHash('sha256').update(`${LOOPBACK}:${upstreamPort}`).digest('base64url')
95
+ return `${COOKIE_PREFIX}${digest}`
96
+ }
97
+
98
+ /** Whether the request already carries a browser-session cookie for this GUI. */
99
+ function hasSessionCookie(request, upstreamPort) {
100
+ const cookie = request.headers.cookie
101
+ return typeof cookie === 'string' && cookie.includes(`${sessionCookieName(upstreamPort)}=`)
102
+ }
103
+
104
+ /** Whether the request is a browser navigation, the only flow `autoToken` helps. */
105
+ function isNavigation(request) {
106
+ if (request.method !== 'GET') return false
107
+ const mode = request.headers['sec-fetch-mode']
108
+ if (typeof mode === 'string') return mode === 'navigate'
109
+ const accept = request.headers.accept
110
+ return typeof accept === 'string' && accept.includes('text/html')
111
+ }
112
+
113
+ /** The process launch token, read from the live Connection service. */
114
+ function launchToken(ctx, upstreamPort) {
115
+ const connection = ctx.get('connection')
116
+ if (connection === undefined) return undefined
117
+ try {
118
+ const url = new URL(connection.authenticatedUrl(`http://${LOOPBACK}:${upstreamPort}`))
119
+ return url.searchParams.get('token') ?? undefined
120
+ } catch {
121
+ return undefined
122
+ }
123
+ }
124
+
125
+ /** Add the launch token to a base URL. */
126
+ function withToken(base, token) {
127
+ return token === undefined ? base : `${base}?token=${encodeURIComponent(token)}`
128
+ }
129
+
130
+ /**
131
+ * Rewrite the forwarded authority to the loopback GUI.
132
+ *
133
+ * `Origin` is rewritten only when it named the authority this request arrived
134
+ * on (a same-origin browser request); anything else keeps its value so the
135
+ * shipped fence can reject it.
136
+ * @param headers - the mutable headers about to be forwarded.
137
+ * @param request - the request as received on the tailnet listener.
138
+ * @param upstreamPort - the loopback GUI port.
139
+ */
140
+ function rewriteAuthority(headers, request, upstreamPort) {
141
+ const received = request.headers.host
142
+ const origin = headers.origin
143
+ if (typeof origin === 'string') {
144
+ try {
145
+ if (new URL(origin).host === received) headers.origin = `http://${LOOPBACK}:${upstreamPort}`
146
+ } catch {
147
+ /* An unparsable Origin keeps its value and is refused downstream. */
148
+ }
149
+ }
150
+ headers.host = `${LOOPBACK}:${upstreamPort}`
151
+ }
152
+
153
+ /** Forward one HTTP request to the loopback GUI. */
154
+ function forwardRequest(ctx, state, request, response) {
155
+ const headers = { ...request.headers }
156
+ rewriteAuthority(headers, request, state.upstreamPort)
157
+ for (const header of HOP_BY_HOP) delete headers[header]
158
+
159
+ const upstream = httpRequest({
160
+ host: LOOPBACK,
161
+ port: state.upstreamPort,
162
+ method: request.method,
163
+ path: request.url,
164
+ headers,
165
+ }, (upstreamResponse) => {
166
+ upstreamResponse.on('error', () => response.destroy())
167
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
168
+ upstreamResponse.pipe(response)
169
+ })
170
+ upstream.on('error', (error) => {
171
+ ctx.logger.warn(`simple-remote: tailnet proxy could not reach ${LOOPBACK}:${state.upstreamPort} (${error.code ?? error.message})`)
172
+ if (response.headersSent) {
173
+ response.destroy()
174
+ return
175
+ }
176
+ response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
177
+ response.end('simple-remote: the Harness Web server on the loopback address is unreachable.\n')
178
+ })
179
+ request.on('error', () => upstream.destroy())
180
+ response.on('error', () => upstream.destroy())
181
+ response.on('close', () => upstream.destroy())
182
+ request.pipe(upstream)
183
+ }
184
+
185
+ /** Forward one WebSocket handshake and then the raw socket both ways. */
186
+ function forwardUpgrade(ctx, state, request, socket, head) {
187
+ const headers = { ...request.headers }
188
+ rewriteAuthority(headers, request, state.upstreamPort)
189
+ delete headers['proxy-connection']
190
+
191
+ const upstream = netConnect(state.upstreamPort, LOOPBACK, () => {
192
+ const lines = [`${request.method ?? 'GET'} ${request.url ?? '/'} HTTP/1.1`]
193
+ for (const [key, value] of Object.entries(headers)) {
194
+ if (value === undefined) continue
195
+ if (Array.isArray(value)) for (const item of value) lines.push(`${key}: ${item}`)
196
+ else lines.push(`${key}: ${value}`)
197
+ }
198
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`)
199
+ if (head !== undefined && head.length > 0) upstream.write(head)
200
+ upstream.pipe(socket)
201
+ socket.pipe(upstream)
202
+ })
203
+ const fail = (error) => {
204
+ ctx.logger.warn(`simple-remote: tailnet upgrade proxy failed (${error.code ?? error.message})`)
205
+ socket.destroy()
206
+ upstream.destroy()
207
+ }
208
+ upstream.on('error', fail)
209
+ socket.on('error', fail)
210
+ socket.on('close', () => upstream.destroy())
211
+ upstream.on('close', () => socket.destroy())
212
+ }
213
+
214
+ /** The value the browser page reads to render its remote-access row. */
215
+ function clientState(state) {
216
+ const base = state.urls[0]
217
+ return {
218
+ enabled: true,
219
+ autoToken: state.autoToken,
220
+ urls: [...state.urls],
221
+ loginUrl: base === undefined ? undefined : withToken(base, state.token),
222
+ }
223
+ }
224
+
225
+ /** Print the remote-access lines once per fact, to the log and to stdout. */
226
+ function announceBase(ctx, state) {
227
+ if (state.announcedBase || state.urls.length === 0) return
228
+ state.announcedBase = true
229
+ const line = `dsh web: tailnet ${state.urls[0]}${state.autoToken ? ' (Tailscale; auto-login)' : ''}`
230
+ ctx.logger.info(line)
231
+ if (state.log) console.log(line)
232
+ }
233
+
234
+ /** Print the tokenized remote URL once the launch token is known. */
235
+ function announceToken(ctx, state) {
236
+ if (state.announcedToken || state.urls.length === 0 || state.token === undefined) return
237
+ state.announcedToken = true
238
+ const line = `dsh web: tailnet with token ${withToken(state.urls[0], state.token)}`
239
+ ctx.logger.info(line)
240
+ if (state.log) console.log(line)
241
+ }
242
+
243
+ /**
244
+ * Mount the tailnet bridge: one listener per tailnet address, the launch-token
245
+ * handshake for cookieless navigations, and the page state the Client half reads.
246
+ * @param ctx - the plugin context carrying `webServer`.
247
+ * @param config - the row config.
248
+ */
249
+ export function apply(ctx, config) {
250
+ const options = readOptions(config)
251
+ if (!options.enabled) return
252
+
253
+ const upstreamPort = ctx.webServer.port
254
+ if (upstreamPort === undefined) {
255
+ ctx.logger.warn('simple-remote: the Web server has no listening port yet; no tailnet listener started')
256
+ return
257
+ }
258
+
259
+ const addresses = detectAddresses(options.address)
260
+ if (addresses.length === 0) {
261
+ ctx.logger.warn('simple-remote: no Tailscale address found on this machine; is Tailscale connected? no tailnet listener started')
262
+ return
263
+ }
264
+
265
+ const state = {
266
+ upstreamPort,
267
+ autoToken: options.autoToken,
268
+ log: options.log,
269
+ token: undefined,
270
+ urls: [],
271
+ announcedBase: false,
272
+ announcedToken: false,
273
+ }
274
+ const servers = []
275
+ // Upgraded sockets outlive their request, so disposal has to end them too.
276
+ const upgradedSockets = new Set()
277
+
278
+ for (const address of addresses) {
279
+ const server = createServer((request, response) => {
280
+ // The first navigation of a device that holds no session cookie gets
281
+ // the launch token, which mints the cookie for this authority.
282
+ if (options.autoToken && isNavigation(request) && state.token !== undefined) {
283
+ let direct
284
+ try {
285
+ direct = new URL(request.url ?? '/', 'http://dsh.invalid')
286
+ } catch {
287
+ direct = undefined
288
+ }
289
+ if (direct !== undefined && direct.pathname === '/' && !direct.searchParams.has('token') && !hasSessionCookie(request, upstreamPort)) {
290
+ response.writeHead(302, {
291
+ location: `/?token=${encodeURIComponent(state.token)}`,
292
+ 'cache-control': 'no-store',
293
+ 'referrer-policy': 'no-referrer',
294
+ })
295
+ response.end()
296
+ return
297
+ }
298
+ }
299
+ forwardRequest(ctx, state, request, response)
300
+ })
301
+ server.on('upgrade', (request, socket, head) => {
302
+ upgradedSockets.add(socket)
303
+ socket.once('close', () => upgradedSockets.delete(socket))
304
+ forwardUpgrade(ctx, state, request, socket, head)
305
+ })
306
+ server.on('error', (error) => {
307
+ ctx.logger.warn(`simple-remote: tailnet listener on ${address} failed (${error.code ?? error.message})`)
308
+ })
309
+ server.listen(options.port > 0 ? options.port : upstreamPort, address, () => {
310
+ const assigned = server.address()
311
+ const port = typeof assigned === 'object' && assigned !== null ? assigned.port : state.upstreamPort
312
+ state.urls.push(`http://${formatHost(address)}:${port}/`)
313
+ announceBase(ctx, state)
314
+ })
315
+ servers.push(server)
316
+ }
317
+
318
+ ctx.effect(() => () => {
319
+ for (const socket of upgradedSockets) socket.destroy()
320
+ upgradedSockets.clear()
321
+ for (const server of servers) {
322
+ // Idle keep-alive sockets would otherwise hold `close()` open forever.
323
+ server.close()
324
+ server.closeAllConnections?.()
325
+ }
326
+ }, 'simple-remote: tailnet listeners')
327
+
328
+ ctx.on('webserver/index-inject', (table) => {
329
+ table.push({ kind: 'global', name: '__DSH_SIMPLE_REMOTE__', value: clientState(state) })
330
+ })
331
+
332
+ // The launch token lives in the Connection service, which may activate
333
+ // before or after this row; resolve it whenever it appears.
334
+ ctx.inject(['connection'], (connectionCtx) => {
335
+ state.token = launchToken(connectionCtx, upstreamPort)
336
+ announceToken(connectionCtx, state)
337
+ if (state.token === undefined) {
338
+ ctx.logger.warn('simple-remote: Connection exposed no launch token; auto-login stays inactive')
339
+ }
340
+ })
341
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Simple Remote",
4
+ "description": "Serve the Web GUI to your own tailnet only: a Tailscale-bound listener proxies to the loopback server, so a phone with Tailscale can open the page."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "简易远程访问",
4
+ "description": "只对自有 tailnet 提供网页界面:在 Tailscale 网卡上开一个监听端口,反向代理到本机回环服务,手机连上 Tailscale 即可打开网页。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "dsh-simple-remote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Expose the DeepSeek Harness Web GUI to your Tailscale tailnet, and to nothing else.",
7
+ "license": "MIT",
8
+ "author": "Ivan Lam",
9
+ "keywords": [
10
+ "deepseek-harness",
11
+ "dsh",
12
+ "dsh-plugin",
13
+ "tailscale",
14
+ "remote-access"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": "./client.js",
19
+ "./package.json": "./package.json",
20
+ "./locale/*.json": "./locale/*.json"
21
+ },
22
+ "icon": "./icon.svg",
23
+ "files": [
24
+ "index.js",
25
+ "client.js",
26
+ "cordis.patch.yml",
27
+ "icon.svg",
28
+ "locale/*.json",
29
+ "README.md",
30
+ "README.en.md",
31
+ "LICENSE"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "immediately": true
40
+ }
41
+ }
42
+ }