@skillpie/dsh-remote 1.0.0 → 1.2.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/README.md +20 -2
- package/lib/file-route.js +159 -0
- package/lib/index.js +25 -2
- package/lib/relay-core.js +316 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,12 +60,30 @@ DeepSeek Harness(DSH)插件:像 ZCodeOnline 远程协助一样,用 **8 位远程
|
|
|
60
60
|
| `relay.mode` | `gui` | 中转面给访客的体验(同上) |
|
|
61
61
|
| `relay.maxVisitorsPerHost` | `8` | 单宿主机并发访客/拼接上限 |
|
|
62
62
|
|
|
63
|
+
## 地址栏隐私(远程码不落 URL)
|
|
64
|
+
|
|
65
|
+
打开 `https://dsh.skillpie.cn/12345678/` 后,页面脚本会把远程码存入浏览器(localStorage + sessionStorage)、立刻用 `history.replaceState` 把地址栏改写为 `https://dsh.skillpie.cn/`,并把 `<base>` 钉到隧道前缀保证资源继续加载。同时注册一个 origin 级 Service Worker(`/dsh-remote-sw.js`):之后直接访问裸域名也会自动恢复到对应宿主机的隧道,**全程不需要把码拼回 URL**。
|
|
66
|
+
|
|
67
|
+
- 转发/收藏裸域名即可再次进入(同一浏览器);`?gui` 参数可跳过恢复(例如访客想看中转站自己的 GUI 或排除故障)。
|
|
68
|
+
- 携带中转站属主会话 cookie(`dsh-auth-*`)的浏览器不受 SW 影响,裸域名始终是中转站自己的 GUI。
|
|
69
|
+
- 宿主机离线时,SW 回源得到的是离线占位页,不会渲染任何宿主内容。
|
|
70
|
+
- 清除站点数据(或换浏览器)后裸域名不再自动恢复,需要原始带码链接重新进入。
|
|
71
|
+
- 码以明文存在访客浏览器 localStorage 中——这是为「无需重复输入」做的权衡;公用电脑请勿勾选记住,或用后清除站点数据。
|
|
72
|
+
|
|
73
|
+
## 访客文件下载
|
|
74
|
+
|
|
75
|
+
远程访问时,文件预览页的打开方式菜单(LibreOffice/WorkBuddy/… 那个)会在**最前面**多出一项「下载文件」:点击后把宿主机磁盘上的该文件经隧道流回访客浏览器,由浏览器弹出保存框存到本地——GUI 本身的菜单只能把路径交给宿主机桌面,远程访客原本无从取回生成的文件(xlsx/pdf/任意类型均可)。
|
|
76
|
+
|
|
77
|
+
- 路径来自预览页头的完整路径显示;页面找不到路径时,退用最近一次通过 mux 请求过的文件路径;都拿不到会提示「无法定位文件」。
|
|
78
|
+
- 下载路由 `/dsh-remote-file` 挂在宿主机自己的 web 服务上,仅接受 GET/HEAD,经隧道访问时由宿主会话 cookie 保护;另外校验一个由宿主 hostId 派生的密钥(`x-dsh-remote-key`,自动注入),防止本机回环的其他进程任意读文件。只允许下载普通文件,单文件上限 512MB。
|
|
79
|
+
- 仅访客态注入该菜单项,宿主机本地 GUI 不受影响。
|
|
80
|
+
|
|
63
81
|
## 安全(重要)
|
|
64
82
|
|
|
65
|
-
- **gui 模式下访客拥有与属主等价的完整 GUI 权限**(可见所有会话、可操作界面、含设置页)
|
|
83
|
+
- **gui 模式下访客拥有与属主等价的完整 GUI 权限**(可见所有会话、可操作界面、含设置页)。远程码就是分享授权:**只把链接给你信任的人**;不再需要时用属主面板「重新生成远程码」立即作废旧链接(浏览器缓存的旧码会失效,SW 恢复到离线占位页)。
|
|
66
84
|
- 远程码/宿主 token:token 只存哈希;owner 会话 cookie 只在宿主机进程内持有并逐请求注入,不进入访客浏览器。
|
|
67
85
|
- 每台宿主机限制并发访客;请求体、帧大小、空闲时间均有上限;帧处理器带异常边界,任何桥接错误只断开对应连接,不会影响宿主进程。
|
|
68
|
-
- 局限:gui 模式是多宿主共享入口,属主面板的「在线访客」仅统计拼接连接;首次加载资源走隧道一次(之后浏览器缓存生效)。
|
|
86
|
+
- 局限:gui 模式是多宿主共享入口,属主面板的「在线访客」仅统计拼接连接;首次加载资源走隧道一次(之后浏览器缓存生效)。Service Worker 需要安全上下文(https 或 localhost),LAN 明文 http 下仅地址栏摘除生效,无自动恢复。
|
|
69
87
|
|
|
70
88
|
## 验证(本仓库)
|
|
71
89
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — host-side visitor file download route.
|
|
3
|
+
*
|
|
4
|
+
* Visitors of the real GUI have no way to save a generated file locally: the
|
|
5
|
+
* built-in file menu only hands paths to the Host desktop (open / reveal),
|
|
6
|
+
* which is invisible to a remote browser. This route reads a file from this
|
|
7
|
+
* host's disk and streams it back with `Content-Disposition: attachment`,
|
|
8
|
+
* so the visitor's browser opens its own save dialog.
|
|
9
|
+
*
|
|
10
|
+
* The route registers on the HOST's own web server under `/dsh-remote-file`;
|
|
11
|
+
* visitor requests reach it through the tunnel (relay prefix proxy or direct
|
|
12
|
+
* pipe) with the bridge-minted owner cookie attached. Everything else is
|
|
13
|
+
* gated by a shared-secret key derived from the host id (`?k=` or the
|
|
14
|
+
* `x-dsh-remote-key` header) so loopback strangers cannot read arbitrary
|
|
15
|
+
* files. The same derivation runs on the relay to stamp the key into the
|
|
16
|
+
* visitor shim, so no protocol change carries it.
|
|
17
|
+
*/
|
|
18
|
+
import { createReadStream } from 'node:fs'
|
|
19
|
+
import { stat } from 'node:fs/promises'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
/** Largest file one download may serve. */
|
|
23
|
+
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
const MIME_BY_EXT = new Map([
|
|
26
|
+
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
27
|
+
['.xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'],
|
|
28
|
+
['.xls', 'application/vnd.ms-excel'],
|
|
29
|
+
['.csv', 'text/csv; charset=utf-8'],
|
|
30
|
+
['.tsv', 'text/tab-separated-values; charset=utf-8'],
|
|
31
|
+
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
32
|
+
['.doc', 'application/msword'],
|
|
33
|
+
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
|
34
|
+
['.ppt', 'application/vnd.ms-powerpoint'],
|
|
35
|
+
['.pdf', 'application/pdf'],
|
|
36
|
+
['.json', 'application/json; charset=utf-8'],
|
|
37
|
+
['.txt', 'text/plain; charset=utf-8'],
|
|
38
|
+
['.md', 'text/markdown; charset=utf-8'],
|
|
39
|
+
['.html', 'text/html; charset=utf-8'],
|
|
40
|
+
['.htm', 'text/html; charset=utf-8'],
|
|
41
|
+
['.xml', 'application/xml; charset=utf-8'],
|
|
42
|
+
['.yml', 'text/yaml; charset=utf-8'],
|
|
43
|
+
['.yaml', 'text/yaml; charset=utf-8'],
|
|
44
|
+
['.png', 'image/png'],
|
|
45
|
+
['.jpg', 'image/jpeg'],
|
|
46
|
+
['.jpeg', 'image/jpeg'],
|
|
47
|
+
['.gif', 'image/gif'],
|
|
48
|
+
['.webp', 'image/webp'],
|
|
49
|
+
['.svg', 'image/svg+xml'],
|
|
50
|
+
['.zip', 'application/zip'],
|
|
51
|
+
['.gz', 'application/gzip'],
|
|
52
|
+
['.mp4', 'video/mp4'],
|
|
53
|
+
['.mp3', 'audio/mpeg'],
|
|
54
|
+
['.wav', 'audio/wav'],
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
export function mimeOf(name) {
|
|
58
|
+
const at = name.lastIndexOf('.')
|
|
59
|
+
if (at === -1) return 'application/octet-stream'
|
|
60
|
+
return MIME_BY_EXT.get(name.slice(at).toLowerCase()) ?? 'application/octet-stream'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** attachment disposition with an UTF-8 form plus an ASCII fallback. */
|
|
64
|
+
export function dispositionOf(name) {
|
|
65
|
+
const clean = String(name ?? '').replace(/[\r\n]/g, ' ').trim() || 'download'
|
|
66
|
+
const fallback = clean.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
|
67
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(clean)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shared secret gating the host's file route. Derived from the host id only,
|
|
72
|
+
* so it survives token regeneration and needs no protocol change: the relay
|
|
73
|
+
* derives the same value from the hello hostId to stamp the visitor shim.
|
|
74
|
+
*/
|
|
75
|
+
export function fileRouteKey(hostId) {
|
|
76
|
+
return createHash('sha256').update(`dsh-remote-file:v1:${hostId}`).digest('hex').slice(0, 32)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Constant-time-ish comparison for short hex strings. */
|
|
80
|
+
function keyMatches(given, expected) {
|
|
81
|
+
const a = Buffer.from(String(given ?? ''))
|
|
82
|
+
const b = Buffer.from(String(expected ?? ''))
|
|
83
|
+
if (a.length !== b.length) return false
|
|
84
|
+
let diff = 0
|
|
85
|
+
for (let i = 0; i < b.length; i++) diff |= a[i] ^ b[i]
|
|
86
|
+
return diff === 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register the download route on the host web server.
|
|
91
|
+
* @param services { webServer: object with register(route) → disposer }
|
|
92
|
+
* @param key string from fileRouteKey(identity.hostId)
|
|
93
|
+
* @param log fn(kind, text)
|
|
94
|
+
* @returns disposer
|
|
95
|
+
*/
|
|
96
|
+
export function registerFileRoute({ webServer, key, log = () => {} }) {
|
|
97
|
+
return webServer.register({
|
|
98
|
+
kind: 'prefix',
|
|
99
|
+
path: '/dsh-remote-file',
|
|
100
|
+
handler: (req, res) => {
|
|
101
|
+
try {
|
|
102
|
+
void handle(req, res)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
log('file-route', `下载路由处理异常(已隔离): ${error?.message ?? error}`)
|
|
105
|
+
try { res.writeHead(500).end() } catch { try { res.destroy() } catch { /* gone */ } }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
async function handle(req, res) {
|
|
111
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
112
|
+
const send = (status, text) => {
|
|
113
|
+
if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
|
|
114
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
|
|
115
|
+
res.end(`${text}\n`)
|
|
116
|
+
}
|
|
117
|
+
if (!url.pathname.startsWith('/dsh-remote-file')) return send(404, 'not found')
|
|
118
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, 'method not allowed')
|
|
119
|
+
// Loopback strangers and forged pages must not read files; only requests
|
|
120
|
+
// carrying the key survive (the visitor shim stamps it, tunnel or direct).
|
|
121
|
+
const given = url.searchParams.get('k') ?? req.headers['x-dsh-remote-key'] ?? ''
|
|
122
|
+
if (!keyMatches(given, key)) return send(403, 'bad download key')
|
|
123
|
+
const filePath = url.searchParams.get('path') ?? ''
|
|
124
|
+
if (!filePath || !filePath.startsWith('/') || filePath.includes('\0')) return send(400, 'path must be absolute')
|
|
125
|
+
let info
|
|
126
|
+
try {
|
|
127
|
+
info = await stat(filePath)
|
|
128
|
+
} catch {
|
|
129
|
+
return send(404, 'file not found')
|
|
130
|
+
}
|
|
131
|
+
if (!info.isFile()) return send(404, 'not a regular file')
|
|
132
|
+
if (info.size > MAX_DOWNLOAD_BYTES) return send(413, 'file too large')
|
|
133
|
+
const name = filePath.split('/').pop() || 'download'
|
|
134
|
+
let stream
|
|
135
|
+
try {
|
|
136
|
+
stream = createReadStream(filePath)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return send(404, `file unreadable: ${error?.message ?? error}`)
|
|
139
|
+
}
|
|
140
|
+
res.writeHead(200, {
|
|
141
|
+
'Content-Type': mimeOf(name),
|
|
142
|
+
'Content-Length': String(info.size),
|
|
143
|
+
'Content-Disposition': dispositionOf(name),
|
|
144
|
+
'Cache-Control': 'no-store',
|
|
145
|
+
'X-Content-Type-Options': 'nosniff',
|
|
146
|
+
})
|
|
147
|
+
stream.on('error', (error) => {
|
|
148
|
+
log('file-route', `下载流中断: ${error?.message ?? error}`)
|
|
149
|
+
try { res.destroy() } catch { res.end() }
|
|
150
|
+
})
|
|
151
|
+
req.on('close', () => { try { stream.destroy() } catch { /* gone */ } })
|
|
152
|
+
if (req.method === 'HEAD') {
|
|
153
|
+
stream.destroy()
|
|
154
|
+
res.end()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
stream.pipe(res)
|
|
158
|
+
}
|
|
159
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import { HOST_UPGRADE_PATH, OWNER_API_PATH, PROTOCOL_VERSION, generateCode, gene
|
|
|
28
28
|
import { HostEngine } from './engine.js'
|
|
29
29
|
import { GuiBridge } from './bridge.js'
|
|
30
30
|
import { RelayLink } from './remote.js'
|
|
31
|
-
import { RelayCore } from './relay-core.js'
|
|
31
|
+
import { RelayCore, serveServiceWorker } from './relay-core.js'
|
|
32
|
+
import { registerFileRoute, fileRouteKey } from './file-route.js'
|
|
32
33
|
import { consoleHtml } from './console.js'
|
|
33
34
|
import { createPipePair } from './pipe.js'
|
|
34
35
|
|
|
@@ -285,6 +286,15 @@ export default {
|
|
|
285
286
|
logLine('owner-api', `属主 API 已挂载(${OWNER_API_PATH})`)
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
// 5.5 Visitor file downloads: stream files from this host to the
|
|
290
|
+
// remote browser with a save dialog (see lib/file-route.js).
|
|
291
|
+
// Host roles only; the key derives from the host id, which the
|
|
292
|
+
// relay also derives from the hello to stamp the visitor shim.
|
|
293
|
+
if (enabled || direct) {
|
|
294
|
+
disposers.push(registerFileRoute({ webServer, key: fileRouteKey(identity.hostId), log: logLine }))
|
|
295
|
+
logLine('file-route', '访客文件下载路由已挂载(/dsh-remote-file)')
|
|
296
|
+
}
|
|
297
|
+
|
|
288
298
|
// 6. Direct mode: the same surface on the local web server.
|
|
289
299
|
if (direct) {
|
|
290
300
|
const [hostEnd, relayEnd] = createPipePair()
|
|
@@ -316,7 +326,20 @@ export default {
|
|
|
316
326
|
logLine('direct', `本机直连路由已挂载(/${identity.code}/,模式 ${mode})`)
|
|
317
327
|
}
|
|
318
328
|
|
|
319
|
-
// 7.
|
|
329
|
+
// 7. Visitor restore worker: conceals the code from the address bar and
|
|
330
|
+
// keeps the bare origin reopening the tunnel (see concealScript). The
|
|
331
|
+
// worker also probes 8-digit code paths and swaps a 404 (unknown or
|
|
332
|
+
// offline-revoked code) for the code-entry dialog.
|
|
333
|
+
if (mode === 'gui' && (direct || relayFaceEnabled)) {
|
|
334
|
+
disposers.push(webServer.register({
|
|
335
|
+
kind: 'exact',
|
|
336
|
+
path: '/dsh-remote-sw.js',
|
|
337
|
+
handler: (req, res) => { try { serveServiceWorker(req, res) } catch { try { res.destroy() } catch { /* gone */ } } },
|
|
338
|
+
}))
|
|
339
|
+
logLine('sw', '访客恢复 Worker 已挂载(/dsh-remote-sw.js)')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 8. Relay face (server deployments only).
|
|
320
343
|
if (relayFaceEnabled) {
|
|
321
344
|
const relayCore = new RelayCore({
|
|
322
345
|
webServer,
|
package/lib/relay-core.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
decodeBin,
|
|
33
33
|
} from './protocol.js'
|
|
34
34
|
import { adaptSocket } from './ws-adapter.js'
|
|
35
|
+
import { fileRouteKey } from './file-route.js'
|
|
35
36
|
|
|
36
37
|
const HELLO_TIMEOUT_MS = 5_000
|
|
37
38
|
const OFFLINE_GRACE_MS = 10 * 60_000
|
|
@@ -64,6 +65,92 @@ export class RelayCore {
|
|
|
64
65
|
this.cmdSeq = 0
|
|
65
66
|
this.timers = new Set()
|
|
66
67
|
this.closed = false
|
|
68
|
+
this.enterDisposers = []
|
|
69
|
+
// 未知远程码兜底:webServer 的前缀匹配是「p 或 p/」语义,任何短于 8 字符
|
|
70
|
+
// 的前缀都命中不了 /<code>/ 路径,所以无法用通配路由接管数字码;改为
|
|
71
|
+
// ①独立输码页路由(供手动访问/边缘转发目标),②由 index.js 用
|
|
72
|
+
// webServer.tapIndex 把守卫脚本注进服务器 SPA 文档——未知码路径命中的
|
|
73
|
+
// 正是 SPA fallback 渲染的 index,守卫脚本据 pathname 弹输码框。
|
|
74
|
+
if (this.mode === 'gui') {
|
|
75
|
+
try {
|
|
76
|
+
this.enterDisposers.push(this.webServer.register({
|
|
77
|
+
kind: 'prefix',
|
|
78
|
+
path: '/dsh-remote-enter',
|
|
79
|
+
handler: (req, res) => {
|
|
80
|
+
try { this.serveEnter(req, res) } catch (error) {
|
|
81
|
+
this.log('relay', `输码页请求失败(已隔离): ${error?.message ?? error}`)
|
|
82
|
+
try { res.writeHead(500).end() } catch { /* gone */ }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}))
|
|
86
|
+
} catch (error) {
|
|
87
|
+
this.log('relay', `输码路由注册失败(已隔离): ${error?.message ?? error}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Standalone code-entry page: `/dsh-remote-enter?code=NNNNNNNN` renders the
|
|
94
|
+
* dialog pre-flagged with that code as invalid; without a code it opens the
|
|
95
|
+
* blank dialog. Also the redirect target for edge-level unknown-code rules.
|
|
96
|
+
*/
|
|
97
|
+
serveEnter(req, res) {
|
|
98
|
+
let code = ''
|
|
99
|
+
try { code = new URL(req.url ?? '/', 'http://localhost').searchParams.get('code') ?? '' } catch { code = '' }
|
|
100
|
+
const send = (status, body, headers = {}) => {
|
|
101
|
+
if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
|
|
102
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers })
|
|
103
|
+
res.end(body)
|
|
104
|
+
}
|
|
105
|
+
const bad = /^[1-9]\d{7}$/.test(code) ? code : ''
|
|
106
|
+
if (code !== '' && bad === '') {
|
|
107
|
+
send(400, 'bad code\n', { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
send(200, this.enterHtml(bad), { 'Content-Type': 'text/html; charset=utf-8' })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Code-entry page: shown for a well-formed but unknown remote code instead
|
|
115
|
+
* of the SPA's 404. Asks the visitor for a valid code and forwards to
|
|
116
|
+
* /<code>/, where the normal conceal+tunnel flow takes over — the entered
|
|
117
|
+
* code never lingers in a form, and a live code behaves exactly like
|
|
118
|
+
* opening its link for the first time.
|
|
119
|
+
*/
|
|
120
|
+
enterHtml(badCode) {
|
|
121
|
+
const hint = badCode
|
|
122
|
+
? `<div class="err">远程码 <code>${badCode}</code> 无效或宿主机未在线,请核对后重试。</div>`
|
|
123
|
+
: ''
|
|
124
|
+
return `<!DOCTYPE html>
|
|
125
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
126
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
127
|
+
<meta name="robots" content="noindex">
|
|
128
|
+
<title>DeepSeek 远程访问</title>
|
|
129
|
+
<style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:#101114;color:#e8eaed;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
130
|
+
.card{width:min(360px,90vw);background:#1a1c21;border:1px solid #2a2d34;border-radius:14px;padding:28px 26px;box-sizing:border-box}
|
|
131
|
+
h1{margin:0 0 6px;font-size:16px;font-weight:600}.sub{margin:0 0 18px;font-size:12.5px;color:#9aa0a6}
|
|
132
|
+
.row{display:flex;gap:8px}
|
|
133
|
+
input{flex:1;min-width:0;background:#101114;border:1px solid #2a2d34;border-radius:8px;color:#e8eaed;padding:9px 12px;font:15px ui-monospace,Menlo,monospace;letter-spacing:.12em;outline:none}
|
|
134
|
+
input:focus{border-color:#6b84ff}
|
|
135
|
+
button{background:#4a5cff;border:0;border-radius:8px;color:#fff;padding:9px 16px;font:14px inherit;cursor:pointer}
|
|
136
|
+
button:hover{background:#5b6bff}
|
|
137
|
+
.err{margin-top:14px;font-size:12.5px;color:#ff8a80;word-break:break-all}code{font-family:ui-monospace,Menlo,monospace;color:#ffb4a9}
|
|
138
|
+
.tip{margin-top:16px;font-size:11.5px;color:#6b7078}</style></head>
|
|
139
|
+
<body><div class="card"><h1>DeepSeek 远程访问</h1><p class="sub">请输入 8 位远程码以连接对应的宿主机。</p>
|
|
140
|
+
${hint}
|
|
141
|
+
<form class="row"><input name="code" inputmode="numeric" autocomplete="off" maxlength="8" placeholder="12345678" autofocus><button type="submit">进入</button></form>
|
|
142
|
+
<div class="tip">远程码由宿主机属主面板提供,仅在宿主机在线时可用。</div></div>
|
|
143
|
+
<script>(function(){
|
|
144
|
+
var f=document.querySelector('form');var i=document.querySelector('input');
|
|
145
|
+
i.addEventListener('input',function(){i.value=i.value.replace(/\\D/g,'').slice(0,8)});
|
|
146
|
+
f.addEventListener('submit',function(ev){
|
|
147
|
+
ev.preventDefault();
|
|
148
|
+
var c=i.value;
|
|
149
|
+
if(!/^[1-9]\\d{7}$/.test(c)){i.focus();return}
|
|
150
|
+
location.assign('/'+c+'/');
|
|
151
|
+
});
|
|
152
|
+
i.focus();
|
|
153
|
+
})();</script></body></html>`
|
|
67
154
|
}
|
|
68
155
|
|
|
69
156
|
later(fn, ms) {
|
|
@@ -220,6 +307,7 @@ export class RelayCore {
|
|
|
220
307
|
httpOps: new Map(),
|
|
221
308
|
splices: new Map(),
|
|
222
309
|
routes: [],
|
|
310
|
+
fileKey: undefined,
|
|
223
311
|
createdAt: Date.now(),
|
|
224
312
|
}
|
|
225
313
|
if (existing) {
|
|
@@ -231,6 +319,7 @@ export class RelayCore {
|
|
|
231
319
|
host.name = typeof hello.name === 'string' ? hello.name.slice(0, 80) : host.name
|
|
232
320
|
host.dsh = typeof hello.dsh === 'string' ? hello.dsh.slice(0, 40) : host.dsh
|
|
233
321
|
host.ip = ip
|
|
322
|
+
host.fileKey = fileRouteKey(hostId)
|
|
234
323
|
host.tokenHash = hashToken(newToken)
|
|
235
324
|
host.socket = socket
|
|
236
325
|
host.lastSeen = Date.now()
|
|
@@ -353,7 +442,7 @@ export class RelayCore {
|
|
|
353
442
|
|
|
354
443
|
offlineHtml(code) {
|
|
355
444
|
return `<!DOCTYPE html>
|
|
356
|
-
<html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>try{localStorage.setItem('dsh-remote-last-code','${code}')}catch(e){}
|
|
445
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>(function(){try{localStorage.setItem('dsh-remote-last-code','${code}');sessionStorage.setItem('dsh-remote-last-code','${code}');if((location.search||'').indexOf('gui')===-1)history.replaceState(null,'','/')}catch(e){}})();</script>
|
|
357
446
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
358
447
|
<title>远程控制 ${code}</title>
|
|
359
448
|
<style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#101114;color:#e8eaed;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.big{font-size:17px}.hint{font-size:12.5px;color:#9aa0a6}code{font-family:ui-monospace,Menlo,monospace;color:#6b84ff}</style></head>
|
|
@@ -389,11 +478,162 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
|
|
|
389
478
|
})(/* dsh-remote visitor shim */);</script>`
|
|
390
479
|
}
|
|
391
480
|
|
|
392
|
-
|
|
481
|
+
/**
|
|
482
|
+
* Code concealment: remember the code, pin relative resolution to the
|
|
483
|
+
* tunnel prefix (history.replaceState would change how `<base href="./">`
|
|
484
|
+
* resolves), strip the code from the visible URL, and register the
|
|
485
|
+
* origin-level service worker so later visits to the bare origin restore
|
|
486
|
+
* the tunnel without the code ever reappearing in the address bar.
|
|
487
|
+
* Injected right after `<base>`; runs before app scripts.
|
|
488
|
+
*/
|
|
489
|
+
concealScript(code) {
|
|
490
|
+
const codeJson = JSON.stringify(code)
|
|
491
|
+
const prefixJson = JSON.stringify(`/${code}/`)
|
|
492
|
+
return `<script>(function(){
|
|
493
|
+
window.__DSH_REMOTE_CONCEAL__=true;
|
|
494
|
+
try{
|
|
495
|
+
var C=${codeJson};
|
|
496
|
+
if((location.search||'').indexOf('gui')!==-1){try{sessionStorage.setItem('dsh-remote-bypass','1')}catch(e){}return}
|
|
497
|
+
var b=document.querySelector('base');
|
|
498
|
+
if(b)b.setAttribute('href',${prefixJson});
|
|
499
|
+
try{localStorage.setItem('dsh-remote-last-code',C)}catch(e){}
|
|
500
|
+
try{sessionStorage.setItem('dsh-remote-last-code',C)}catch(e){}
|
|
501
|
+
try{history.replaceState(null,'','/')}catch(e){}
|
|
502
|
+
if(navigator.serviceWorker&&window.isSecureContext){
|
|
503
|
+
navigator.serviceWorker.register('/dsh-remote-sw.js?code='+C,{scope:'/'}).catch(function(){})
|
|
504
|
+
}
|
|
505
|
+
}catch(e){}
|
|
506
|
+
})(/* dsh-remote conceal */);</script>`
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Visitor-side "download file" menu patch. The host GUI's file menu
|
|
511
|
+
* (OpenTargetButton) only hands paths to the host desktop; a remote
|
|
512
|
+
* visitor has no way to save the file locally. This script watches for
|
|
513
|
+
* that menu opening, prepends a "下载文件" item (styled after the native
|
|
514
|
+
* menu rows) and serves the file through the host's `/dsh-remote-file`
|
|
515
|
+
* route via the tunnel.
|
|
516
|
+
*
|
|
517
|
+
* The absolute path comes from the preview header's PathLabel
|
|
518
|
+
* (`[data-path-label]` holds it in `title`); as a fallback we tap mux
|
|
519
|
+
* frames for the last path the app requested (openWorkspacePath /
|
|
520
|
+
* workspacePathApplications payloads carry `"path":"…"`).
|
|
521
|
+
* Injected last, after the visitor shim (its WebSocket wrapper stays).
|
|
522
|
+
*/
|
|
523
|
+
downloadMenuScript(code, key) {
|
|
524
|
+
const codeJson = JSON.stringify(code)
|
|
525
|
+
const keyJson = JSON.stringify(String(key ?? ''))
|
|
526
|
+
return `<script>(function(){
|
|
527
|
+
window.__DSH_REMOTE_DOWNLOAD__=true;
|
|
528
|
+
try{
|
|
529
|
+
var C=${codeJson};var K=${keyJson};
|
|
530
|
+
var M=/显示文件位置|Show file location|ファイルの場所| 파일 위치|Открыть расположение/;
|
|
531
|
+
var PATH_RE=/^([\\/~]|[A-Za-z]:[\\\\\\/])/;
|
|
532
|
+
function isPath(s){return typeof s==='string'&&s.length>1&&s.length<1024&&PATH_RE.test(s)&&s.indexOf('/')>-1}
|
|
533
|
+
var lastMux=null;
|
|
534
|
+
function notePath(p){if(isPath(p))lastMux={path:p,at:Date.now()}}
|
|
535
|
+
var OW=window.WebSocket;
|
|
536
|
+
window.WebSocket=class extends OW{constructor(u,p){super(u,p);var self=this;var s=super.send;
|
|
537
|
+
// tap outgoing mux frames for file paths (fallback when no PathLabel DOM)
|
|
538
|
+
this.send=function(data){
|
|
539
|
+
try{
|
|
540
|
+
if(typeof data==='string'&&data.indexOf('"path"')>-1){
|
|
541
|
+
var m=data.match(/"path"\\s*:\\s*"((?:[^"\\\\]|\\\\(?:u[0-9a-fA-F]{4}|.))*)"/);
|
|
542
|
+
if(m){try{notePath(JSON.parse('"'+m[1]+'"'))}catch(e){}}
|
|
543
|
+
}
|
|
544
|
+
}catch(e){}
|
|
545
|
+
return s.call(self,data)}
|
|
546
|
+
}};
|
|
547
|
+
function base(){return location.pathname.replace(new RegExp('^/'+C+'(/|$)'),'/')==='/'
|
|
548
|
+
? '/'+C : location.pathname.split('/').slice(0,2).join('/')}
|
|
549
|
+
function download(path){
|
|
550
|
+
var url=base()+'/dsh-remote-file?path='+encodeURIComponent(path);
|
|
551
|
+
return fetch(url,{credentials:'omit',headers:{'x-dsh-remote-key':K}}).then(function(r){
|
|
552
|
+
if(!r.ok)throw new Error('HTTP '+r.status);
|
|
553
|
+
var cd=r.headers.get('content-disposition')||'';
|
|
554
|
+
var m=cd.match(/filename\\*=UTF-8''([^;]+)/i);
|
|
555
|
+
var name=m?decodeURIComponent(m[1]):(path.split('/').pop()||'download');
|
|
556
|
+
return r.blob().then(function(b){
|
|
557
|
+
var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
|
|
558
|
+
document.body.appendChild(a);a.click();a.remove();
|
|
559
|
+
setTimeout(function(){URL.revokeObjectURL(a.href)},30000);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function findLabel(el){
|
|
564
|
+
for(var n=el,i=0;n&&i<8;i++,n=n.parentElement){
|
|
565
|
+
var lbl=n.querySelector&&n.querySelector('[data-path-label]');
|
|
566
|
+
if(lbl){var t=lbl.getAttribute('title')||lbl.textContent;if(isPath(t))return t}
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
function patch(menu){
|
|
571
|
+
if(menu.getAttribute('data-dsh-remote-dl'))return;
|
|
572
|
+
menu.setAttribute('data-dsh-remote-dl','1');
|
|
573
|
+
var items=menu.querySelectorAll('[role=menuitem]');
|
|
574
|
+
if(!items.length)return;
|
|
575
|
+
var itemsArr=Array.prototype.slice.call(items);
|
|
576
|
+
// Only file menus: the native reveal item, or a menu anchored by the file target.
|
|
577
|
+
var trigger=null;
|
|
578
|
+
var mid=menu.id||'';
|
|
579
|
+
if(mid){var cands=document.querySelectorAll('[aria-controls]');
|
|
580
|
+
for(var i=0;i<cands.length;i++){var v=(cands[i].getAttribute('aria-controls')||'').split(/\\s+/);
|
|
581
|
+
if(v.indexOf(mid)>-1){trigger=cands[i];break}}}
|
|
582
|
+
var hasReveal=itemsArr.some(function(it){return M.test(it.textContent||'')});
|
|
583
|
+
var isFile=hasReveal||(trigger&&trigger.closest&&trigger.closest('[data-open-target="file"]'));
|
|
584
|
+
if(!isFile)return;
|
|
585
|
+
var first=itemsArr[0];
|
|
586
|
+
var btn=document.createElement('button');
|
|
587
|
+
btn.setAttribute('role','menuitem');
|
|
588
|
+
btn.setAttribute('type','button');
|
|
589
|
+
btn.className=first.className||'';
|
|
590
|
+
btn.style.cssText='all:unset;display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;cursor:pointer;color:inherit;font:inherit;';
|
|
591
|
+
for(var a=0;a<first.attributes.length;a++){var at=first.attributes[a];
|
|
592
|
+
if(at.name!=='role'&&at.name!=='type'&&at.name!=='disabled'&&at.name.indexOf('data-')!==0&&!/^(id|aria-(labelledby|describedby))$/.test(at.name)){try{btn.setAttribute(at.name,at.value)}catch(e){}}}
|
|
593
|
+
btn.setAttribute('data-dsh-remote-dl-item','1');
|
|
594
|
+
var zh=(document.documentElement.lang||'').toLowerCase().indexOf('zh')===0;
|
|
595
|
+
btn.innerHTML='<span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;opacity:.85">'
|
|
596
|
+
+'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2.2v7.6m0 0 2.8-2.8M8 9.8 5.2 7M3 12.6h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
|
597
|
+
+'</span><span></span>';
|
|
598
|
+
btn.lastElementChild.textContent=zh?'下载文件':'Download file';
|
|
599
|
+
btn.addEventListener('click',function(ev){
|
|
600
|
+
ev.preventDefault();ev.stopPropagation();
|
|
601
|
+
var path=findLabel(trigger||first)||((lastMux&&Date.now()-(lastMux.at||0)<120000)?lastMux.path:null);
|
|
602
|
+
if(!path){
|
|
603
|
+
var t=btn.lastElementChild;var old=t.textContent;
|
|
604
|
+
t.textContent=zh?'无法定位文件':'Cannot locate file';
|
|
605
|
+
setTimeout(function(){t.textContent=old},1200);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
download(path).catch(function(){});
|
|
609
|
+
});
|
|
610
|
+
first.parentNode.insertBefore(btn,first);
|
|
611
|
+
}
|
|
612
|
+
var mo=new MutationObserver(function(muts){
|
|
613
|
+
for(var i=0;i<muts.length;i++){var add=muts[i].addedNodes;
|
|
614
|
+
for(var j=0;j<add.length;j++){var n=add[j];
|
|
615
|
+
if(!n||n.nodeType!==1)continue;
|
|
616
|
+
var menus=n.matches&&n.matches('[role=menu]')?[n]:(n.querySelectorAll?n.querySelectorAll('[role=menu]'):[]);
|
|
617
|
+
for(var k=0;k<menus.length;k++){try{patch(menus[k])}catch(e){}}
|
|
618
|
+
}}
|
|
619
|
+
});
|
|
620
|
+
function start(){try{mo.observe(document.body,{childList:true,subtree:true})}catch(e){}}
|
|
621
|
+
if(document.body)start();else document.addEventListener('DOMContentLoaded',start);
|
|
622
|
+
// Menus already open before the observer started.
|
|
623
|
+
try{document.querySelectorAll('[role=menu]').forEach(patch)}catch(e){}
|
|
624
|
+
}catch(e){}
|
|
625
|
+
})(/* dsh-remote download menu */);</script>`
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
injectVisitorShim(html, code, fileKey) {
|
|
393
629
|
if (typeof html !== 'string' || html.length === 0) return html
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
|
|
630
|
+
let scripts = this.concealScript(code) + this.visitorShimScript(code)
|
|
631
|
+
if (typeof fileKey === 'string' && fileKey.length > 0) {
|
|
632
|
+
scripts += this.downloadMenuScript(code, fileKey)
|
|
633
|
+
}
|
|
634
|
+
if (/<base[^>]*>/i.test(html)) return html.replace(/<base[^>]*>/i, (m) => m + scripts)
|
|
635
|
+
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + scripts)
|
|
636
|
+
return scripts + html
|
|
397
637
|
}
|
|
398
638
|
|
|
399
639
|
proxyHttp(req, res, code, host) {
|
|
@@ -552,7 +792,7 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
|
|
|
552
792
|
op.done = true
|
|
553
793
|
try {
|
|
554
794
|
if (op.htmlInject) {
|
|
555
|
-
const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code)
|
|
795
|
+
const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code, host.fileKey)
|
|
556
796
|
op.res.end(html)
|
|
557
797
|
} else {
|
|
558
798
|
op.res.end()
|
|
@@ -797,6 +1037,8 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
|
|
|
797
1037
|
|
|
798
1038
|
detachAll() {
|
|
799
1039
|
this.closed = true
|
|
1040
|
+
for (const dispose of this.enterDisposers) { try { dispose() } catch { /* gone */ } }
|
|
1041
|
+
this.enterDisposers = []
|
|
800
1042
|
for (const timer of this.timers) clearTimeout(timer)
|
|
801
1043
|
this.timers.clear()
|
|
802
1044
|
for (const hostId of [...this.hosts.keys()]) this.removeHost(hostId, 'relay shutting down')
|
|
@@ -821,3 +1063,71 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
|
|
|
821
1063
|
}
|
|
822
1064
|
|
|
823
1065
|
export { PROTOCOL_VERSION }
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Origin-level service worker served at /dsh-remote-sw.js. It reads its own
|
|
1069
|
+
* scriptURL query for the code and, from then on, intercepts navigations to
|
|
1070
|
+
* the bare origin (which belong to the relay's authenticated GUI and 401 for
|
|
1071
|
+
* visitors) and serves the tunnel under /<code>/ instead — the address bar
|
|
1072
|
+
* keeps showing the bare origin. Carriers of the relay server's own session
|
|
1073
|
+
* cookie (`dsh-auth-…`) pass through untouched, so the relay owner's GUI is
|
|
1074
|
+
* never hijacked, and `?gui` is a manual escape.
|
|
1075
|
+
*/
|
|
1076
|
+
export function serviceWorkerScript() {
|
|
1077
|
+
return `/* dsh-remote visitor restore worker */
|
|
1078
|
+
var CODE='';
|
|
1079
|
+
try{CODE=new URL(self.location.href).searchParams.get('code')||''}catch(e){}
|
|
1080
|
+
var CODE_RE=/^\\/([1-9]\\d{7})(\\/|$)/;
|
|
1081
|
+
self.addEventListener('install',function(e){self.skipWaiting()});
|
|
1082
|
+
self.addEventListener('activate',function(e){e.waitUntil(self.clients.claim())});
|
|
1083
|
+
self.addEventListener('fetch',function(event){
|
|
1084
|
+
try{
|
|
1085
|
+
var req=event.request;
|
|
1086
|
+
if(req.method!=='GET')return;
|
|
1087
|
+
if(req.mode!=='navigate')return;
|
|
1088
|
+
var url=new URL(req.url);
|
|
1089
|
+
if(url.origin!==self.location.origin)return;
|
|
1090
|
+
if((url.search||'').indexOf('gui')!==-1)return;
|
|
1091
|
+
if((req.headers.get('cookie')||'').indexOf('dsh-auth-')!==-1)return;
|
|
1092
|
+
/* Unknown-code guard: an 8-digit path that answers 404 (no live host)
|
|
1093
|
+
gets the code-entry dialog instead of the bare 404 page. */
|
|
1094
|
+
var cm=url.pathname.match(CODE_RE);
|
|
1095
|
+
if(cm){
|
|
1096
|
+
event.respondWith((async function(){
|
|
1097
|
+
var probe;
|
|
1098
|
+
try{probe=await fetch(new Request(req.url,{method:'HEAD',cache:'no-store'}))}catch(e){return fetch(req)}
|
|
1099
|
+
if(probe&&probe.status===404){
|
|
1100
|
+
try{return await fetch('/dsh-remote-enter?code='+cm[1],{cache:'no-store'})}catch(e){return fetch(req)}
|
|
1101
|
+
}
|
|
1102
|
+
return fetch(req);
|
|
1103
|
+
})());
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if(url.pathname!=='/'&&url.pathname!=='')return;
|
|
1107
|
+
if(!/^[1-9]\\d{7}$/.test(CODE))return;
|
|
1108
|
+
event.respondWith((async function(){
|
|
1109
|
+
var up;
|
|
1110
|
+
try{up=await fetch('/'+CODE+'/',{cache:'no-store'})}catch(e){return}
|
|
1111
|
+
if(up.status===404){
|
|
1112
|
+
try{return await fetch('/dsh-remote-enter?code='+CODE,{cache:'no-store'})}catch(e){return fetch(req)}
|
|
1113
|
+
}
|
|
1114
|
+
var ct=up.headers.get('content-type')||'';
|
|
1115
|
+
if(ct.indexOf('text/html')===-1)return up;
|
|
1116
|
+
var text=await up.text();
|
|
1117
|
+
if(text.indexOf('__DSH_REMOTE_VISITOR__')===-1)return fetch(req);
|
|
1118
|
+
return new Response(text,{status:up.status,statusText:up.statusText,headers:up.headers});
|
|
1119
|
+
})());
|
|
1120
|
+
}catch(e){}
|
|
1121
|
+
});
|
|
1122
|
+
`
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export function serveServiceWorker(req, res) {
|
|
1126
|
+
res.writeHead(200, {
|
|
1127
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
1128
|
+
'Cache-Control': 'no-store',
|
|
1129
|
+
'Service-Worker-Allowed': '/',
|
|
1130
|
+
})
|
|
1131
|
+
res.end(serviceWorkerScript())
|
|
1132
|
+
}
|
|
1133
|
+
|
package/package.json
CHANGED