@skillpie/dsh-remote 0.0.0-stage → 1.1.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 +95 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +14 -0
- package/lib/bridge.js +377 -0
- package/lib/client.js +493 -0
- package/lib/console.js +434 -0
- package/lib/engine.js +367 -0
- package/lib/file-route.js +159 -0
- package/lib/index.js +496 -0
- package/lib/pipe.js +83 -0
- package/lib/protocol.js +122 -0
- package/lib/relay-core.js +1025 -0
- package/lib/remote.js +181 -0
- package/lib/ws-adapter.js +49 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — Cordis entry (both roles in one activation).
|
|
3
|
+
*
|
|
4
|
+
* Host role (default on): dials the configured relay origin (e.g.
|
|
5
|
+
* wss://dsh.skillpie.cn) over one outbound WebSocket and registers this
|
|
6
|
+
* machine's 8-digit code. Visitors opening `https://<relay>/<code>/` use
|
|
7
|
+
* this machine's real DSH Web GUI through the tunnel (`mode: 'gui'`,
|
|
8
|
+
* default) or a lightweight read/control console (`mode: 'console'`).
|
|
9
|
+
* With `direct: true` the same surface also mounts locally at `/<code>`
|
|
10
|
+
* (LAN / loopback use and end-to-end testing without a relay).
|
|
11
|
+
*
|
|
12
|
+
* Relay role (`relay.enabled`, for a server-side profile such as
|
|
13
|
+
* dsh.skillpie.cn): mounts `/dsh-remote/host` for host registrations and
|
|
14
|
+
* serves `/<code>/` for registered hosts. The registry keeps token hashes
|
|
15
|
+
* only; GUI traffic rides spliced frames the relay never interprets.
|
|
16
|
+
*
|
|
17
|
+
* Identity (code / hostId / token) persists as a credential record
|
|
18
|
+
* `dsh-remote/identity` (GrantRecord payload); an explicit `code` in the
|
|
19
|
+
* row config pins it instead.
|
|
20
|
+
*
|
|
21
|
+
* Owner panel: same-origin JSON API at /dsh-remote/api behind the web GUI's
|
|
22
|
+
* own session auth (status / regenerate / toggles), consumed by the client
|
|
23
|
+
* half's sidebar panel. Host roles only — a relay-only server row mounts no
|
|
24
|
+
* owner API.
|
|
25
|
+
*/
|
|
26
|
+
import os from 'node:os'
|
|
27
|
+
import { HOST_UPGRADE_PATH, OWNER_API_PATH, PROTOCOL_VERSION, generateCode, generateId, isValidCode } from './protocol.js'
|
|
28
|
+
import { HostEngine } from './engine.js'
|
|
29
|
+
import { GuiBridge } from './bridge.js'
|
|
30
|
+
import { RelayLink } from './remote.js'
|
|
31
|
+
import { RelayCore, serveServiceWorker } from './relay-core.js'
|
|
32
|
+
import { registerFileRoute, fileRouteKey } from './file-route.js'
|
|
33
|
+
import { consoleHtml } from './console.js'
|
|
34
|
+
import { createPipePair } from './pipe.js'
|
|
35
|
+
|
|
36
|
+
const DEFAULT_RELAY_URL = 'wss://dsh.skillpie.cn'
|
|
37
|
+
const CREDENTIAL_KEY = 'dsh-remote/identity'
|
|
38
|
+
const MAX_BODY = 32 * 1024
|
|
39
|
+
|
|
40
|
+
/* ── config ──────────────────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
/** One issue in the Standard Schema config vocabulary. */
|
|
43
|
+
const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
|
|
44
|
+
|
|
45
|
+
function bool(value, fallback) {
|
|
46
|
+
if (value === undefined || value === null) return fallback
|
|
47
|
+
if (typeof value === 'boolean') return value
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const Config = {
|
|
52
|
+
'~standard': {
|
|
53
|
+
version: 1,
|
|
54
|
+
vendor: 'dsh-remote',
|
|
55
|
+
validate(input) {
|
|
56
|
+
if (input === undefined || input === null) return { value: {} }
|
|
57
|
+
if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
|
|
58
|
+
const issues = []
|
|
59
|
+
const value = {}
|
|
60
|
+
const bools = [
|
|
61
|
+
['enabled', true],
|
|
62
|
+
['allowControl', true],
|
|
63
|
+
['direct', true],
|
|
64
|
+
]
|
|
65
|
+
for (const [key, fallback] of bools) {
|
|
66
|
+
const parsed = bool(input[key], fallback)
|
|
67
|
+
if (parsed === null) issues.push(issue('expected a boolean', key))
|
|
68
|
+
else value[key] = parsed
|
|
69
|
+
}
|
|
70
|
+
if (input.mode !== undefined) {
|
|
71
|
+
if (input.mode !== 'gui' && input.mode !== 'console') {
|
|
72
|
+
issues.push(issue("expected 'gui' or 'console'", 'mode'))
|
|
73
|
+
} else value.mode = input.mode
|
|
74
|
+
}
|
|
75
|
+
if (input.relayUrl !== undefined) {
|
|
76
|
+
if (typeof input.relayUrl !== 'string' || !input.relayUrl.trim()) {
|
|
77
|
+
issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
|
|
78
|
+
} else {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(input.relayUrl.trim())
|
|
81
|
+
if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol) || (url.pathname && url.pathname !== '/')) {
|
|
82
|
+
issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
|
|
83
|
+
} else {
|
|
84
|
+
value.relayUrl = url.origin
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const key of ['name', 'hostId', 'token']) {
|
|
92
|
+
const raw = input[key]
|
|
93
|
+
if (raw === undefined) continue
|
|
94
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
95
|
+
issues.push(issue('expected a non-empty string', key))
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
value[key] = raw.trim()
|
|
99
|
+
}
|
|
100
|
+
if (input.code !== undefined) {
|
|
101
|
+
if (!isValidCode(input.code)) issues.push(issue('expected an 8-digit code (no leading zero)', 'code'))
|
|
102
|
+
else value.code = input.code
|
|
103
|
+
}
|
|
104
|
+
if (input.relay !== undefined) {
|
|
105
|
+
if (typeof input.relay !== 'object' || input.relay === null || Array.isArray(input.relay)) {
|
|
106
|
+
issues.push(issue('expected an object', 'relay'))
|
|
107
|
+
} else {
|
|
108
|
+
const relay = {}
|
|
109
|
+
const relayEnabled = bool(input.relay.enabled, false)
|
|
110
|
+
if (relayEnabled === null) issues.push(issue('expected a boolean', 'relay', 'enabled'))
|
|
111
|
+
else relay.enabled = relayEnabled
|
|
112
|
+
if (input.relay.mode !== undefined) {
|
|
113
|
+
if (input.relay.mode !== 'gui' && input.relay.mode !== 'console') {
|
|
114
|
+
issues.push(issue("expected 'gui' or 'console'", 'relay', 'mode'))
|
|
115
|
+
} else relay.mode = input.relay.mode
|
|
116
|
+
}
|
|
117
|
+
if (input.relay.maxVisitorsPerHost !== undefined) {
|
|
118
|
+
const n = input.relay.maxVisitorsPerHost
|
|
119
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 64) {
|
|
120
|
+
issues.push(issue('expected an integer between 1 and 64', 'relay', 'maxVisitorsPerHost'))
|
|
121
|
+
} else relay.maxVisitorsPerHost = n
|
|
122
|
+
}
|
|
123
|
+
value.relay = relay
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return issues.length ? { issues } : { value }
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ── apply ───────────────────────────────────────────────────────────── */
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
name: '@skillpie/dsh-remote',
|
|
135
|
+
Config,
|
|
136
|
+
inject: [],
|
|
137
|
+
apply(ctx, rawConfig = null) {
|
|
138
|
+
const cfg = rawConfig ?? {}
|
|
139
|
+
const enabled = cfg.enabled ?? true
|
|
140
|
+
const allowControl = cfg.allowControl ?? true
|
|
141
|
+
const direct = cfg.direct ?? true
|
|
142
|
+
const mode = cfg.mode === 'console' ? 'console' : 'gui'
|
|
143
|
+
const relayUrl = cfg.relayUrl ?? DEFAULT_RELAY_URL
|
|
144
|
+
const relayFaceEnabled = cfg.relay?.enabled === true
|
|
145
|
+
const relayMode = cfg.relay?.mode === 'console' ? 'console' : 'gui'
|
|
146
|
+
const maxVisitorsPerHost = cfg.relay?.maxVisitorsPerHost ?? 8
|
|
147
|
+
|
|
148
|
+
const logRing = []
|
|
149
|
+
const logLine = (kind, text) => {
|
|
150
|
+
logRing.push({ time: Date.now(), kind, text })
|
|
151
|
+
if (logRing.length > 200) logRing.shift()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Provisional identity; boot() may replace it with the stored record
|
|
155
|
+
// before any route or connection mounts.
|
|
156
|
+
const identity = {
|
|
157
|
+
code: isValidCode(cfg.code) ? cfg.code : generateCode(),
|
|
158
|
+
hostId: typeof cfg.hostId === 'string' && cfg.hostId ? cfg.hostId : generateId(),
|
|
159
|
+
token: typeof cfg.token === 'string' ? cfg.token : '',
|
|
160
|
+
name: cfg.name ?? os.hostname(),
|
|
161
|
+
dsh: `node ${process.version}`,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Everything boot() builds registers its disposer here; the effect is
|
|
165
|
+
// registered synchronously so teardown is guaranteed even when boot has
|
|
166
|
+
// not finished.
|
|
167
|
+
const state = { peer: undefined, link: undefined, directCore: undefined, relayCore: undefined }
|
|
168
|
+
const disposers = []
|
|
169
|
+
ctx.effect(() => () => {
|
|
170
|
+
state.link?.stop()
|
|
171
|
+
if (mode === 'gui') state.peer?.detach?.()
|
|
172
|
+
else state.peer?.close?.()
|
|
173
|
+
for (const dispose of disposers.splice(0)) {
|
|
174
|
+
try { dispose() } catch { /* best-effort teardown */ }
|
|
175
|
+
}
|
|
176
|
+
logLine('boot', 'dsh-remote 已卸载')
|
|
177
|
+
}, 'dsh-remote: lifetime')
|
|
178
|
+
|
|
179
|
+
// Services arrive through optional injection; boot() starts the parts
|
|
180
|
+
// that need them as they appear.
|
|
181
|
+
const services = { sessionController: undefined, webServer: undefined, webServerOrigin: undefined }
|
|
182
|
+
let webServerWaiter = undefined
|
|
183
|
+
const whenWebServer = () => services.webServer
|
|
184
|
+
? Promise.resolve()
|
|
185
|
+
: new Promise((resolve) => { webServerWaiter = resolve })
|
|
186
|
+
|
|
187
|
+
ctx.inject(['sessionController'], (child) => {
|
|
188
|
+
services.sessionController = child.sessionController
|
|
189
|
+
})
|
|
190
|
+
ctx.inject(['webServer'], (child) => {
|
|
191
|
+
services.webServer = child.webServer
|
|
192
|
+
try { services.webServerOrigin = child.webServer.getOrigin() } catch { services.webServerOrigin = undefined }
|
|
193
|
+
if (webServerWaiter) { const resolve = webServerWaiter; webServerWaiter = undefined; resolve() }
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
void boot().catch((error) => {
|
|
197
|
+
logLine('boot', `启动失败: ${error?.message ?? error}`)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
async function boot() {
|
|
201
|
+
// 1. Adopt the stored identity unless the row config pins one.
|
|
202
|
+
const credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
203
|
+
let stored = undefined
|
|
204
|
+
if (credentials) {
|
|
205
|
+
try {
|
|
206
|
+
const record = await credentials.readRecord(CREDENTIAL_KEY)
|
|
207
|
+
if (record?.kind === 'grant' && record.payload && typeof record.payload === 'object') {
|
|
208
|
+
stored = record.payload
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logLine('identity', `凭据读取失败: ${error?.message ?? error}`)
|
|
212
|
+
}
|
|
213
|
+
if (stored && !cfg.code) {
|
|
214
|
+
if (isValidCode(stored.code)) identity.code = stored.code
|
|
215
|
+
if (typeof stored.hostId === 'string' && stored.hostId) identity.hostId = stored.hostId
|
|
216
|
+
if (typeof stored.token === 'string') identity.token = stored.token
|
|
217
|
+
}
|
|
218
|
+
if (!cfg.code || !cfg.hostId) {
|
|
219
|
+
// Persist the (possibly fresh) identity; no restart involved.
|
|
220
|
+
try {
|
|
221
|
+
await saveIdentity(credentials, { code: identity.code, hostId: identity.hostId, token: identity.token })
|
|
222
|
+
} catch (error) {
|
|
223
|
+
logLine('identity', `身份持久化失败(重启后会再次生成): ${error?.message ?? error}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
logLine('identity', '凭据服务不可用,身份仅本次会话有效')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2. The web surface carries everything below.
|
|
231
|
+
await whenWebServer()
|
|
232
|
+
if (!services.webServer) return
|
|
233
|
+
const webServer = services.webServer
|
|
234
|
+
|
|
235
|
+
// 3. Build the visitor-facing peer: the GUI bridge (gui mode) or the
|
|
236
|
+
// lightweight command engine (console mode).
|
|
237
|
+
let peer
|
|
238
|
+
if (mode === 'gui') {
|
|
239
|
+
peer = new GuiBridge({
|
|
240
|
+
services: {
|
|
241
|
+
webServer: () => services.webServer,
|
|
242
|
+
connection: () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined),
|
|
243
|
+
},
|
|
244
|
+
log: logLine,
|
|
245
|
+
})
|
|
246
|
+
} else {
|
|
247
|
+
const engine = new HostEngine({
|
|
248
|
+
services: { sessionController: () => services.sessionController },
|
|
249
|
+
host: { get name() { return identity.name }, get dsh() { return identity.dsh }, get code() { return identity.code } },
|
|
250
|
+
log: logLine,
|
|
251
|
+
})
|
|
252
|
+
engine.allowControl = allowControl
|
|
253
|
+
disposers.push(engine.observeSessionEvents((name, handler) => ctx.on(name, handler)))
|
|
254
|
+
peer = engine
|
|
255
|
+
}
|
|
256
|
+
state.peer = peer
|
|
257
|
+
|
|
258
|
+
// 4. Outbound relay link.
|
|
259
|
+
if (enabled) {
|
|
260
|
+
const link = new RelayLink({
|
|
261
|
+
relayUrl,
|
|
262
|
+
identity,
|
|
263
|
+
peer,
|
|
264
|
+
log: logLine,
|
|
265
|
+
onToken: (token) => {
|
|
266
|
+
identity.token = token
|
|
267
|
+
const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
268
|
+
if (creds) saveIdentity(creds, { token }).catch(() => {})
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
state.link = link
|
|
272
|
+
link.start()
|
|
273
|
+
logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},模式 ${mode})`)
|
|
274
|
+
} else {
|
|
275
|
+
logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},远程访问已关闭)`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 5. Owner panel API (same-origin, behind the GUI session auth). Host
|
|
279
|
+
// roles only — a relay-only server row mounts no owner API.
|
|
280
|
+
if (enabled || direct) {
|
|
281
|
+
disposers.push(webServer.register({
|
|
282
|
+
kind: 'exact',
|
|
283
|
+
path: OWNER_API_PATH,
|
|
284
|
+
handler: (req, res) => { void ownerApi(req, res) },
|
|
285
|
+
}))
|
|
286
|
+
logLine('owner-api', `属主 API 已挂载(${OWNER_API_PATH})`)
|
|
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
|
+
|
|
298
|
+
// 6. Direct mode: the same surface on the local web server.
|
|
299
|
+
if (direct) {
|
|
300
|
+
const [hostEnd, relayEnd] = createPipePair()
|
|
301
|
+
const directCore = new RelayCore({
|
|
302
|
+
webServer,
|
|
303
|
+
consoleHtml,
|
|
304
|
+
log: logLine,
|
|
305
|
+
maxVisitorsPerHost,
|
|
306
|
+
mode,
|
|
307
|
+
})
|
|
308
|
+
directCore.acceptHostSocket(relayEnd)
|
|
309
|
+
// The pipe replaces RelayLink's transport, so it speaks the same
|
|
310
|
+
// registration handshake before the peer attaches.
|
|
311
|
+
hostEnd.send({
|
|
312
|
+
t: 'hello',
|
|
313
|
+
v: PROTOCOL_VERSION,
|
|
314
|
+
hostId: identity.hostId,
|
|
315
|
+
code: identity.code,
|
|
316
|
+
token: identity.token ?? '',
|
|
317
|
+
name: identity.name,
|
|
318
|
+
dsh: identity.dsh,
|
|
319
|
+
})
|
|
320
|
+
peer.attach(hostEnd)
|
|
321
|
+
state.directCore = directCore
|
|
322
|
+
disposers.push(() => {
|
|
323
|
+
directCore.detachAll()
|
|
324
|
+
hostEnd.close()
|
|
325
|
+
})
|
|
326
|
+
logLine('direct', `本机直连路由已挂载(/${identity.code}/,模式 ${mode})`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 7. Visitor restore worker: conceals the code from the address bar and
|
|
330
|
+
// keeps the bare origin reopening the tunnel (see concealScript).
|
|
331
|
+
if (mode === 'gui' && (direct || relayFaceEnabled)) {
|
|
332
|
+
disposers.push(webServer.register({
|
|
333
|
+
kind: 'exact',
|
|
334
|
+
path: '/dsh-remote-sw.js',
|
|
335
|
+
handler: (req, res) => { try { serveServiceWorker(req, res) } catch { try { res.destroy() } catch { /* gone */ } } },
|
|
336
|
+
}))
|
|
337
|
+
logLine('sw', '访客恢复 Worker 已挂载(/dsh-remote-sw.js)')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 8. Relay face (server deployments only).
|
|
341
|
+
if (relayFaceEnabled) {
|
|
342
|
+
const relayCore = new RelayCore({
|
|
343
|
+
webServer,
|
|
344
|
+
consoleHtml,
|
|
345
|
+
log: logLine,
|
|
346
|
+
maxVisitorsPerHost,
|
|
347
|
+
mode: relayMode,
|
|
348
|
+
})
|
|
349
|
+
state.relayCore = relayCore
|
|
350
|
+
disposers.push(webServer.registerUpgrade({
|
|
351
|
+
path: HOST_UPGRADE_PATH,
|
|
352
|
+
handler: (req, socket, head) => relayCore.acceptHostUpgrade(req, socket, head),
|
|
353
|
+
}))
|
|
354
|
+
disposers.push(() => relayCore.detachAll())
|
|
355
|
+
logLine('relay-face', `中转面已挂载(${HOST_UPGRADE_PATH},模式 ${relayMode})`)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function ownerApi(req, res) {
|
|
360
|
+
const send = (status, payload) => {
|
|
361
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
362
|
+
res.end(JSON.stringify(payload))
|
|
363
|
+
}
|
|
364
|
+
const origin = req.headers?.origin
|
|
365
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
366
|
+
let sameOrigin = false
|
|
367
|
+
try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
|
|
368
|
+
if (!sameOrigin) return send(403, { ok: false, error: 'cross-origin request refused' })
|
|
369
|
+
}
|
|
370
|
+
let body = {}
|
|
371
|
+
if (req.method === 'POST') {
|
|
372
|
+
body = await readBody(req).catch(() => null)
|
|
373
|
+
if (body === null) return send(200, { ok: false, error: 'invalid JSON body' })
|
|
374
|
+
} else if (req.method !== 'GET') {
|
|
375
|
+
return send(200, { ok: true, service: 'dsh-remote', protocol: PROTOCOL_VERSION })
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
send(200, await handleOwnerAction(body))
|
|
379
|
+
} catch (error) {
|
|
380
|
+
send(200, { ok: false, error: error?.message ?? String(error) })
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function handleOwnerAction(body) {
|
|
385
|
+
const action = typeof body?.action === 'string' ? body.action : 'status'
|
|
386
|
+
switch (action) {
|
|
387
|
+
case 'status':
|
|
388
|
+
return { ok: true, status: statusSnapshot() }
|
|
389
|
+
case 'regenerate': {
|
|
390
|
+
const code = generateCode()
|
|
391
|
+
identity.code = code
|
|
392
|
+
identity.token = ''
|
|
393
|
+
const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
394
|
+
if (creds) await saveIdentity(creds, { code, hostId: identity.hostId, token: '' }).catch(() => {})
|
|
395
|
+
logLine('owner', `属主重新生成远程码 → ${code}`)
|
|
396
|
+
return { ok: true, code }
|
|
397
|
+
}
|
|
398
|
+
case 'set-enabled': {
|
|
399
|
+
const next = body.value === true
|
|
400
|
+
if (next && !state.link) {
|
|
401
|
+
const link = new RelayLink({
|
|
402
|
+
relayUrl,
|
|
403
|
+
identity,
|
|
404
|
+
peer: state.peer,
|
|
405
|
+
log: logLine,
|
|
406
|
+
onToken: (token) => {
|
|
407
|
+
identity.token = token
|
|
408
|
+
const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
409
|
+
if (creds) saveIdentity(creds, { token }).catch(() => {})
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
state.link = link
|
|
413
|
+
link.start()
|
|
414
|
+
} else if (!next && state.link) {
|
|
415
|
+
state.link.stop()
|
|
416
|
+
state.link = undefined
|
|
417
|
+
}
|
|
418
|
+
logLine('owner', `属主${next ? '开启' : '关闭'}远程控制`)
|
|
419
|
+
return { ok: true, enabled: next }
|
|
420
|
+
}
|
|
421
|
+
case 'set-control': {
|
|
422
|
+
const next = body.value === true
|
|
423
|
+
if (state.peer?.allowControl !== undefined) {
|
|
424
|
+
state.peer.allowControl = next
|
|
425
|
+
broadcastHostUpdate()
|
|
426
|
+
}
|
|
427
|
+
logLine('owner', `属主${next ? '允许' : '禁止'}访客控制会话`)
|
|
428
|
+
return { ok: true, allowControl: next }
|
|
429
|
+
}
|
|
430
|
+
default:
|
|
431
|
+
throw new Error(`unknown action: ${action}`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function broadcastHostUpdate() {
|
|
436
|
+
const peer = state.peer
|
|
437
|
+
if (!peer?.visitors) return
|
|
438
|
+
const frame = { t: 'host-updated', host: { name: identity.name, dsh: identity.dsh, allowControl: peer.allowControl } }
|
|
439
|
+
for (const visitorId of peer.visitors.keys()) peer.pushTo(visitorId, frame)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function statusSnapshot() {
|
|
443
|
+
const link = state.link
|
|
444
|
+
return {
|
|
445
|
+
protocol: PROTOCOL_VERSION,
|
|
446
|
+
mode,
|
|
447
|
+
enabled: Boolean(link),
|
|
448
|
+
allowControl: state.peer?.allowControl ?? allowControl,
|
|
449
|
+
direct,
|
|
450
|
+
relayUrl,
|
|
451
|
+
name: identity.name,
|
|
452
|
+
hostId: identity.hostId,
|
|
453
|
+
code: identity.code,
|
|
454
|
+
relay: link ? link.status() : { connected: false, relayUrl },
|
|
455
|
+
directUrl: direct && services.webServerOrigin ? `${services.webServerOrigin}/${identity.code}/` : undefined,
|
|
456
|
+
relayUrlPublic: relayUrl.replace(/^ws/, 'http') + '/' + identity.code + '/',
|
|
457
|
+
visitors: state.peer?.status ? state.peer.status().visitors : [],
|
|
458
|
+
relayFace: relayFaceEnabled ? (state.relayCore?.status() ?? { hosts: [] }) : undefined,
|
|
459
|
+
log: logRing.slice(-80),
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ── helpers ─────────────────────────────────────────────────────────── */
|
|
466
|
+
|
|
467
|
+
async function saveIdentity(credentials, patch) {
|
|
468
|
+
await credentials.modifyRecord(CREDENTIAL_KEY, (current) => {
|
|
469
|
+
const payload = current?.kind === 'grant' && current.payload && typeof current.payload === 'object'
|
|
470
|
+
? { ...current.payload }
|
|
471
|
+
: {}
|
|
472
|
+
return { kind: 'grant', payload: { ...payload, ...patch } }
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readBody(req) {
|
|
477
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
478
|
+
let data = ''
|
|
479
|
+
req.on('data', (chunk) => {
|
|
480
|
+
data += chunk
|
|
481
|
+
if (data.length > MAX_BODY) {
|
|
482
|
+
req.destroy()
|
|
483
|
+
rejectPromise(new Error('request body too large'))
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
req.on('end', () => {
|
|
487
|
+
if (!data.trim()) return resolvePromise({})
|
|
488
|
+
try {
|
|
489
|
+
resolvePromise(JSON.parse(data))
|
|
490
|
+
} catch {
|
|
491
|
+
rejectPromise(new Error('invalid JSON body'))
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
req.on('error', rejectPromise)
|
|
495
|
+
})
|
|
496
|
+
}
|
package/lib/pipe.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — in-process ws-like pipe pair.
|
|
3
|
+
*
|
|
4
|
+
* Direct mode reuses the exact relay code path on the local web server: the
|
|
5
|
+
* engine or GUI bridge speaks the same frames to one end of a pipe while a
|
|
6
|
+
* RelayCore instance serves the other end. JSON text frames cross
|
|
7
|
+
* serialization (so framing bugs show up locally too); binary splice frames
|
|
8
|
+
* pass through by reference.
|
|
9
|
+
*
|
|
10
|
+
* Both ends expose the subset of the `ws` surface the plugin uses:
|
|
11
|
+
* send(obj) JSON text frame
|
|
12
|
+
* sendRaw(buffer) binary passthrough
|
|
13
|
+
* onMessage(cb) cb(text | Buffer)
|
|
14
|
+
* onClose(cb), close()
|
|
15
|
+
*/
|
|
16
|
+
function makeEnd(getPeer) {
|
|
17
|
+
const end = {
|
|
18
|
+
open: true,
|
|
19
|
+
messageHandlers: new Set(),
|
|
20
|
+
closeHandlers: new Set(),
|
|
21
|
+
send(obj) {
|
|
22
|
+
if (!end.open) return
|
|
23
|
+
const text = JSON.stringify(obj)
|
|
24
|
+
const peer = getPeer()
|
|
25
|
+
// Deliver on a microtask so neither side re-enters synchronously.
|
|
26
|
+
queueMicrotask(() => {
|
|
27
|
+
if (!end.open || !peer || !peer.open) return
|
|
28
|
+
for (const handler of peer.messageHandlers) {
|
|
29
|
+
try { handler(text) } catch { /* contained */ }
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
sendRaw(buffer) {
|
|
34
|
+
if (!end.open) return
|
|
35
|
+
const peer = getPeer()
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (!end.open || !peer || !peer.open) return
|
|
38
|
+
for (const handler of peer.messageHandlers) {
|
|
39
|
+
try { handler(buffer) } catch { /* contained */ }
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
sendText(text) {
|
|
44
|
+
if (!end.open) return
|
|
45
|
+
const peer = getPeer()
|
|
46
|
+
queueMicrotask(() => {
|
|
47
|
+
if (!end.open || !peer || !peer.open) return
|
|
48
|
+
for (const handler of peer.messageHandlers) {
|
|
49
|
+
try { handler(text) } catch { /* contained */ }
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
onMessage(handler) {
|
|
54
|
+
end.messageHandlers.add(handler)
|
|
55
|
+
return () => end.messageHandlers.delete(handler)
|
|
56
|
+
},
|
|
57
|
+
onClose(handler) {
|
|
58
|
+
end.closeHandlers.add(handler)
|
|
59
|
+
return () => end.closeHandlers.delete(handler)
|
|
60
|
+
},
|
|
61
|
+
close() {
|
|
62
|
+
if (!end.open) return
|
|
63
|
+
end.open = false
|
|
64
|
+
const handlers = [...end.closeHandlers]
|
|
65
|
+
end.messageHandlers.clear()
|
|
66
|
+
end.closeHandlers.clear()
|
|
67
|
+
for (const handler of handlers) handler()
|
|
68
|
+
const peer = getPeer()
|
|
69
|
+
if (peer && peer.open) peer.close()
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
return end
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createPipePair() {
|
|
76
|
+
let peerOfA
|
|
77
|
+
let peerOfB
|
|
78
|
+
const a = makeEnd(() => peerOfA)
|
|
79
|
+
const b = makeEnd(() => peerOfB)
|
|
80
|
+
peerOfA = b
|
|
81
|
+
peerOfB = a
|
|
82
|
+
return [a, b]
|
|
83
|
+
}
|