@skillpie/dsh-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/README.md +77 -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/index.js +475 -0
- package/lib/pipe.js +83 -0
- package/lib/protocol.js +122 -0
- package/lib/relay-core.js +823 -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,475 @@
|
|
|
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 } from './relay-core.js'
|
|
32
|
+
import { consoleHtml } from './console.js'
|
|
33
|
+
import { createPipePair } from './pipe.js'
|
|
34
|
+
|
|
35
|
+
const DEFAULT_RELAY_URL = 'wss://dsh.skillpie.cn'
|
|
36
|
+
const CREDENTIAL_KEY = 'dsh-remote/identity'
|
|
37
|
+
const MAX_BODY = 32 * 1024
|
|
38
|
+
|
|
39
|
+
/* ── config ──────────────────────────────────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
/** One issue in the Standard Schema config vocabulary. */
|
|
42
|
+
const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
|
|
43
|
+
|
|
44
|
+
function bool(value, fallback) {
|
|
45
|
+
if (value === undefined || value === null) return fallback
|
|
46
|
+
if (typeof value === 'boolean') return value
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Config = {
|
|
51
|
+
'~standard': {
|
|
52
|
+
version: 1,
|
|
53
|
+
vendor: 'dsh-remote',
|
|
54
|
+
validate(input) {
|
|
55
|
+
if (input === undefined || input === null) return { value: {} }
|
|
56
|
+
if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
|
|
57
|
+
const issues = []
|
|
58
|
+
const value = {}
|
|
59
|
+
const bools = [
|
|
60
|
+
['enabled', true],
|
|
61
|
+
['allowControl', true],
|
|
62
|
+
['direct', true],
|
|
63
|
+
]
|
|
64
|
+
for (const [key, fallback] of bools) {
|
|
65
|
+
const parsed = bool(input[key], fallback)
|
|
66
|
+
if (parsed === null) issues.push(issue('expected a boolean', key))
|
|
67
|
+
else value[key] = parsed
|
|
68
|
+
}
|
|
69
|
+
if (input.mode !== undefined) {
|
|
70
|
+
if (input.mode !== 'gui' && input.mode !== 'console') {
|
|
71
|
+
issues.push(issue("expected 'gui' or 'console'", 'mode'))
|
|
72
|
+
} else value.mode = input.mode
|
|
73
|
+
}
|
|
74
|
+
if (input.relayUrl !== undefined) {
|
|
75
|
+
if (typeof input.relayUrl !== 'string' || !input.relayUrl.trim()) {
|
|
76
|
+
issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
|
|
77
|
+
} else {
|
|
78
|
+
try {
|
|
79
|
+
const url = new URL(input.relayUrl.trim())
|
|
80
|
+
if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol) || (url.pathname && url.pathname !== '/')) {
|
|
81
|
+
issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
|
|
82
|
+
} else {
|
|
83
|
+
value.relayUrl = url.origin
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const key of ['name', 'hostId', 'token']) {
|
|
91
|
+
const raw = input[key]
|
|
92
|
+
if (raw === undefined) continue
|
|
93
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
94
|
+
issues.push(issue('expected a non-empty string', key))
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
value[key] = raw.trim()
|
|
98
|
+
}
|
|
99
|
+
if (input.code !== undefined) {
|
|
100
|
+
if (!isValidCode(input.code)) issues.push(issue('expected an 8-digit code (no leading zero)', 'code'))
|
|
101
|
+
else value.code = input.code
|
|
102
|
+
}
|
|
103
|
+
if (input.relay !== undefined) {
|
|
104
|
+
if (typeof input.relay !== 'object' || input.relay === null || Array.isArray(input.relay)) {
|
|
105
|
+
issues.push(issue('expected an object', 'relay'))
|
|
106
|
+
} else {
|
|
107
|
+
const relay = {}
|
|
108
|
+
const relayEnabled = bool(input.relay.enabled, false)
|
|
109
|
+
if (relayEnabled === null) issues.push(issue('expected a boolean', 'relay', 'enabled'))
|
|
110
|
+
else relay.enabled = relayEnabled
|
|
111
|
+
if (input.relay.mode !== undefined) {
|
|
112
|
+
if (input.relay.mode !== 'gui' && input.relay.mode !== 'console') {
|
|
113
|
+
issues.push(issue("expected 'gui' or 'console'", 'relay', 'mode'))
|
|
114
|
+
} else relay.mode = input.relay.mode
|
|
115
|
+
}
|
|
116
|
+
if (input.relay.maxVisitorsPerHost !== undefined) {
|
|
117
|
+
const n = input.relay.maxVisitorsPerHost
|
|
118
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 64) {
|
|
119
|
+
issues.push(issue('expected an integer between 1 and 64', 'relay', 'maxVisitorsPerHost'))
|
|
120
|
+
} else relay.maxVisitorsPerHost = n
|
|
121
|
+
}
|
|
122
|
+
value.relay = relay
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return issues.length ? { issues } : { value }
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ── apply ───────────────────────────────────────────────────────────── */
|
|
131
|
+
|
|
132
|
+
export default {
|
|
133
|
+
name: '@skillpie/dsh-remote',
|
|
134
|
+
Config,
|
|
135
|
+
inject: [],
|
|
136
|
+
apply(ctx, rawConfig = null) {
|
|
137
|
+
const cfg = rawConfig ?? {}
|
|
138
|
+
const enabled = cfg.enabled ?? true
|
|
139
|
+
const allowControl = cfg.allowControl ?? true
|
|
140
|
+
const direct = cfg.direct ?? true
|
|
141
|
+
const mode = cfg.mode === 'console' ? 'console' : 'gui'
|
|
142
|
+
const relayUrl = cfg.relayUrl ?? DEFAULT_RELAY_URL
|
|
143
|
+
const relayFaceEnabled = cfg.relay?.enabled === true
|
|
144
|
+
const relayMode = cfg.relay?.mode === 'console' ? 'console' : 'gui'
|
|
145
|
+
const maxVisitorsPerHost = cfg.relay?.maxVisitorsPerHost ?? 8
|
|
146
|
+
|
|
147
|
+
const logRing = []
|
|
148
|
+
const logLine = (kind, text) => {
|
|
149
|
+
logRing.push({ time: Date.now(), kind, text })
|
|
150
|
+
if (logRing.length > 200) logRing.shift()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Provisional identity; boot() may replace it with the stored record
|
|
154
|
+
// before any route or connection mounts.
|
|
155
|
+
const identity = {
|
|
156
|
+
code: isValidCode(cfg.code) ? cfg.code : generateCode(),
|
|
157
|
+
hostId: typeof cfg.hostId === 'string' && cfg.hostId ? cfg.hostId : generateId(),
|
|
158
|
+
token: typeof cfg.token === 'string' ? cfg.token : '',
|
|
159
|
+
name: cfg.name ?? os.hostname(),
|
|
160
|
+
dsh: `node ${process.version}`,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Everything boot() builds registers its disposer here; the effect is
|
|
164
|
+
// registered synchronously so teardown is guaranteed even when boot has
|
|
165
|
+
// not finished.
|
|
166
|
+
const state = { peer: undefined, link: undefined, directCore: undefined, relayCore: undefined }
|
|
167
|
+
const disposers = []
|
|
168
|
+
ctx.effect(() => () => {
|
|
169
|
+
state.link?.stop()
|
|
170
|
+
if (mode === 'gui') state.peer?.detach?.()
|
|
171
|
+
else state.peer?.close?.()
|
|
172
|
+
for (const dispose of disposers.splice(0)) {
|
|
173
|
+
try { dispose() } catch { /* best-effort teardown */ }
|
|
174
|
+
}
|
|
175
|
+
logLine('boot', 'dsh-remote 已卸载')
|
|
176
|
+
}, 'dsh-remote: lifetime')
|
|
177
|
+
|
|
178
|
+
// Services arrive through optional injection; boot() starts the parts
|
|
179
|
+
// that need them as they appear.
|
|
180
|
+
const services = { sessionController: undefined, webServer: undefined, webServerOrigin: undefined }
|
|
181
|
+
let webServerWaiter = undefined
|
|
182
|
+
const whenWebServer = () => services.webServer
|
|
183
|
+
? Promise.resolve()
|
|
184
|
+
: new Promise((resolve) => { webServerWaiter = resolve })
|
|
185
|
+
|
|
186
|
+
ctx.inject(['sessionController'], (child) => {
|
|
187
|
+
services.sessionController = child.sessionController
|
|
188
|
+
})
|
|
189
|
+
ctx.inject(['webServer'], (child) => {
|
|
190
|
+
services.webServer = child.webServer
|
|
191
|
+
try { services.webServerOrigin = child.webServer.getOrigin() } catch { services.webServerOrigin = undefined }
|
|
192
|
+
if (webServerWaiter) { const resolve = webServerWaiter; webServerWaiter = undefined; resolve() }
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
void boot().catch((error) => {
|
|
196
|
+
logLine('boot', `启动失败: ${error?.message ?? error}`)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
async function boot() {
|
|
200
|
+
// 1. Adopt the stored identity unless the row config pins one.
|
|
201
|
+
const credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
202
|
+
let stored = undefined
|
|
203
|
+
if (credentials) {
|
|
204
|
+
try {
|
|
205
|
+
const record = await credentials.readRecord(CREDENTIAL_KEY)
|
|
206
|
+
if (record?.kind === 'grant' && record.payload && typeof record.payload === 'object') {
|
|
207
|
+
stored = record.payload
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
logLine('identity', `凭据读取失败: ${error?.message ?? error}`)
|
|
211
|
+
}
|
|
212
|
+
if (stored && !cfg.code) {
|
|
213
|
+
if (isValidCode(stored.code)) identity.code = stored.code
|
|
214
|
+
if (typeof stored.hostId === 'string' && stored.hostId) identity.hostId = stored.hostId
|
|
215
|
+
if (typeof stored.token === 'string') identity.token = stored.token
|
|
216
|
+
}
|
|
217
|
+
if (!cfg.code || !cfg.hostId) {
|
|
218
|
+
// Persist the (possibly fresh) identity; no restart involved.
|
|
219
|
+
try {
|
|
220
|
+
await saveIdentity(credentials, { code: identity.code, hostId: identity.hostId, token: identity.token })
|
|
221
|
+
} catch (error) {
|
|
222
|
+
logLine('identity', `身份持久化失败(重启后会再次生成): ${error?.message ?? error}`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
logLine('identity', '凭据服务不可用,身份仅本次会话有效')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2. The web surface carries everything below.
|
|
230
|
+
await whenWebServer()
|
|
231
|
+
if (!services.webServer) return
|
|
232
|
+
const webServer = services.webServer
|
|
233
|
+
|
|
234
|
+
// 3. Build the visitor-facing peer: the GUI bridge (gui mode) or the
|
|
235
|
+
// lightweight command engine (console mode).
|
|
236
|
+
let peer
|
|
237
|
+
if (mode === 'gui') {
|
|
238
|
+
peer = new GuiBridge({
|
|
239
|
+
services: {
|
|
240
|
+
webServer: () => services.webServer,
|
|
241
|
+
connection: () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined),
|
|
242
|
+
},
|
|
243
|
+
log: logLine,
|
|
244
|
+
})
|
|
245
|
+
} else {
|
|
246
|
+
const engine = new HostEngine({
|
|
247
|
+
services: { sessionController: () => services.sessionController },
|
|
248
|
+
host: { get name() { return identity.name }, get dsh() { return identity.dsh }, get code() { return identity.code } },
|
|
249
|
+
log: logLine,
|
|
250
|
+
})
|
|
251
|
+
engine.allowControl = allowControl
|
|
252
|
+
disposers.push(engine.observeSessionEvents((name, handler) => ctx.on(name, handler)))
|
|
253
|
+
peer = engine
|
|
254
|
+
}
|
|
255
|
+
state.peer = peer
|
|
256
|
+
|
|
257
|
+
// 4. Outbound relay link.
|
|
258
|
+
if (enabled) {
|
|
259
|
+
const link = new RelayLink({
|
|
260
|
+
relayUrl,
|
|
261
|
+
identity,
|
|
262
|
+
peer,
|
|
263
|
+
log: logLine,
|
|
264
|
+
onToken: (token) => {
|
|
265
|
+
identity.token = token
|
|
266
|
+
const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
267
|
+
if (creds) saveIdentity(creds, { token }).catch(() => {})
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
state.link = link
|
|
271
|
+
link.start()
|
|
272
|
+
logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},模式 ${mode})`)
|
|
273
|
+
} else {
|
|
274
|
+
logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},远程访问已关闭)`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 5. Owner panel API (same-origin, behind the GUI session auth). Host
|
|
278
|
+
// roles only — a relay-only server row mounts no owner API.
|
|
279
|
+
if (enabled || direct) {
|
|
280
|
+
disposers.push(webServer.register({
|
|
281
|
+
kind: 'exact',
|
|
282
|
+
path: OWNER_API_PATH,
|
|
283
|
+
handler: (req, res) => { void ownerApi(req, res) },
|
|
284
|
+
}))
|
|
285
|
+
logLine('owner-api', `属主 API 已挂载(${OWNER_API_PATH})`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 6. Direct mode: the same surface on the local web server.
|
|
289
|
+
if (direct) {
|
|
290
|
+
const [hostEnd, relayEnd] = createPipePair()
|
|
291
|
+
const directCore = new RelayCore({
|
|
292
|
+
webServer,
|
|
293
|
+
consoleHtml,
|
|
294
|
+
log: logLine,
|
|
295
|
+
maxVisitorsPerHost,
|
|
296
|
+
mode,
|
|
297
|
+
})
|
|
298
|
+
directCore.acceptHostSocket(relayEnd)
|
|
299
|
+
// The pipe replaces RelayLink's transport, so it speaks the same
|
|
300
|
+
// registration handshake before the peer attaches.
|
|
301
|
+
hostEnd.send({
|
|
302
|
+
t: 'hello',
|
|
303
|
+
v: PROTOCOL_VERSION,
|
|
304
|
+
hostId: identity.hostId,
|
|
305
|
+
code: identity.code,
|
|
306
|
+
token: identity.token ?? '',
|
|
307
|
+
name: identity.name,
|
|
308
|
+
dsh: identity.dsh,
|
|
309
|
+
})
|
|
310
|
+
peer.attach(hostEnd)
|
|
311
|
+
state.directCore = directCore
|
|
312
|
+
disposers.push(() => {
|
|
313
|
+
directCore.detachAll()
|
|
314
|
+
hostEnd.close()
|
|
315
|
+
})
|
|
316
|
+
logLine('direct', `本机直连路由已挂载(/${identity.code}/,模式 ${mode})`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 7. Relay face (server deployments only).
|
|
320
|
+
if (relayFaceEnabled) {
|
|
321
|
+
const relayCore = new RelayCore({
|
|
322
|
+
webServer,
|
|
323
|
+
consoleHtml,
|
|
324
|
+
log: logLine,
|
|
325
|
+
maxVisitorsPerHost,
|
|
326
|
+
mode: relayMode,
|
|
327
|
+
})
|
|
328
|
+
state.relayCore = relayCore
|
|
329
|
+
disposers.push(webServer.registerUpgrade({
|
|
330
|
+
path: HOST_UPGRADE_PATH,
|
|
331
|
+
handler: (req, socket, head) => relayCore.acceptHostUpgrade(req, socket, head),
|
|
332
|
+
}))
|
|
333
|
+
disposers.push(() => relayCore.detachAll())
|
|
334
|
+
logLine('relay-face', `中转面已挂载(${HOST_UPGRADE_PATH},模式 ${relayMode})`)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function ownerApi(req, res) {
|
|
339
|
+
const send = (status, payload) => {
|
|
340
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
341
|
+
res.end(JSON.stringify(payload))
|
|
342
|
+
}
|
|
343
|
+
const origin = req.headers?.origin
|
|
344
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
345
|
+
let sameOrigin = false
|
|
346
|
+
try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
|
|
347
|
+
if (!sameOrigin) return send(403, { ok: false, error: 'cross-origin request refused' })
|
|
348
|
+
}
|
|
349
|
+
let body = {}
|
|
350
|
+
if (req.method === 'POST') {
|
|
351
|
+
body = await readBody(req).catch(() => null)
|
|
352
|
+
if (body === null) return send(200, { ok: false, error: 'invalid JSON body' })
|
|
353
|
+
} else if (req.method !== 'GET') {
|
|
354
|
+
return send(200, { ok: true, service: 'dsh-remote', protocol: PROTOCOL_VERSION })
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
send(200, await handleOwnerAction(body))
|
|
358
|
+
} catch (error) {
|
|
359
|
+
send(200, { ok: false, error: error?.message ?? String(error) })
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function handleOwnerAction(body) {
|
|
364
|
+
const action = typeof body?.action === 'string' ? body.action : 'status'
|
|
365
|
+
switch (action) {
|
|
366
|
+
case 'status':
|
|
367
|
+
return { ok: true, status: statusSnapshot() }
|
|
368
|
+
case 'regenerate': {
|
|
369
|
+
const code = generateCode()
|
|
370
|
+
identity.code = code
|
|
371
|
+
identity.token = ''
|
|
372
|
+
const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
373
|
+
if (creds) await saveIdentity(creds, { code, hostId: identity.hostId, token: '' }).catch(() => {})
|
|
374
|
+
logLine('owner', `属主重新生成远程码 → ${code}`)
|
|
375
|
+
return { ok: true, code }
|
|
376
|
+
}
|
|
377
|
+
case 'set-enabled': {
|
|
378
|
+
const next = body.value === true
|
|
379
|
+
if (next && !state.link) {
|
|
380
|
+
const link = new RelayLink({
|
|
381
|
+
relayUrl,
|
|
382
|
+
identity,
|
|
383
|
+
peer: state.peer,
|
|
384
|
+
log: logLine,
|
|
385
|
+
onToken: (token) => {
|
|
386
|
+
identity.token = token
|
|
387
|
+
const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
388
|
+
if (creds) saveIdentity(creds, { token }).catch(() => {})
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
state.link = link
|
|
392
|
+
link.start()
|
|
393
|
+
} else if (!next && state.link) {
|
|
394
|
+
state.link.stop()
|
|
395
|
+
state.link = undefined
|
|
396
|
+
}
|
|
397
|
+
logLine('owner', `属主${next ? '开启' : '关闭'}远程控制`)
|
|
398
|
+
return { ok: true, enabled: next }
|
|
399
|
+
}
|
|
400
|
+
case 'set-control': {
|
|
401
|
+
const next = body.value === true
|
|
402
|
+
if (state.peer?.allowControl !== undefined) {
|
|
403
|
+
state.peer.allowControl = next
|
|
404
|
+
broadcastHostUpdate()
|
|
405
|
+
}
|
|
406
|
+
logLine('owner', `属主${next ? '允许' : '禁止'}访客控制会话`)
|
|
407
|
+
return { ok: true, allowControl: next }
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
throw new Error(`unknown action: ${action}`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function broadcastHostUpdate() {
|
|
415
|
+
const peer = state.peer
|
|
416
|
+
if (!peer?.visitors) return
|
|
417
|
+
const frame = { t: 'host-updated', host: { name: identity.name, dsh: identity.dsh, allowControl: peer.allowControl } }
|
|
418
|
+
for (const visitorId of peer.visitors.keys()) peer.pushTo(visitorId, frame)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function statusSnapshot() {
|
|
422
|
+
const link = state.link
|
|
423
|
+
return {
|
|
424
|
+
protocol: PROTOCOL_VERSION,
|
|
425
|
+
mode,
|
|
426
|
+
enabled: Boolean(link),
|
|
427
|
+
allowControl: state.peer?.allowControl ?? allowControl,
|
|
428
|
+
direct,
|
|
429
|
+
relayUrl,
|
|
430
|
+
name: identity.name,
|
|
431
|
+
hostId: identity.hostId,
|
|
432
|
+
code: identity.code,
|
|
433
|
+
relay: link ? link.status() : { connected: false, relayUrl },
|
|
434
|
+
directUrl: direct && services.webServerOrigin ? `${services.webServerOrigin}/${identity.code}/` : undefined,
|
|
435
|
+
relayUrlPublic: relayUrl.replace(/^ws/, 'http') + '/' + identity.code + '/',
|
|
436
|
+
visitors: state.peer?.status ? state.peer.status().visitors : [],
|
|
437
|
+
relayFace: relayFaceEnabled ? (state.relayCore?.status() ?? { hosts: [] }) : undefined,
|
|
438
|
+
log: logRing.slice(-80),
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* ── helpers ─────────────────────────────────────────────────────────── */
|
|
445
|
+
|
|
446
|
+
async function saveIdentity(credentials, patch) {
|
|
447
|
+
await credentials.modifyRecord(CREDENTIAL_KEY, (current) => {
|
|
448
|
+
const payload = current?.kind === 'grant' && current.payload && typeof current.payload === 'object'
|
|
449
|
+
? { ...current.payload }
|
|
450
|
+
: {}
|
|
451
|
+
return { kind: 'grant', payload: { ...payload, ...patch } }
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function readBody(req) {
|
|
456
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
457
|
+
let data = ''
|
|
458
|
+
req.on('data', (chunk) => {
|
|
459
|
+
data += chunk
|
|
460
|
+
if (data.length > MAX_BODY) {
|
|
461
|
+
req.destroy()
|
|
462
|
+
rejectPromise(new Error('request body too large'))
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
req.on('end', () => {
|
|
466
|
+
if (!data.trim()) return resolvePromise({})
|
|
467
|
+
try {
|
|
468
|
+
resolvePromise(JSON.parse(data))
|
|
469
|
+
} catch {
|
|
470
|
+
rejectPromise(new Error('invalid JSON body'))
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
req.on('error', rejectPromise)
|
|
474
|
+
})
|
|
475
|
+
}
|
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
|
+
}
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — shared wire constants and small helpers.
|
|
3
|
+
*
|
|
4
|
+
* Control channel (relay ↔ host, JSON text frames over one WebSocket):
|
|
5
|
+
* host → relay { t:'hello', v, hostId, code, token, name, dsh }
|
|
6
|
+
* { t:'hb' } heartbeat, every 15s
|
|
7
|
+
* { t:'res', id, ok, data?, error? } command receipts
|
|
8
|
+
* { t:'push', to, f } event push addressed to one visitor
|
|
9
|
+
* relay → host { t:'hello', v, ok, token?, error? } ack; token minted on first registration
|
|
10
|
+
* { t:'hb' } heartbeat echo
|
|
11
|
+
* { t:'cmd', id, from, cmd, p? } visitor command (from = visitor id)
|
|
12
|
+
*
|
|
13
|
+
* Visitor channel (relay ↔ browser, JSON text frames over /<code>/ws):
|
|
14
|
+
* relay → visitor { t:'welcome', v, visitor, host, sessions }
|
|
15
|
+
* { t:'res', id, ok, data?, error? }
|
|
16
|
+
* { t:'push', f }
|
|
17
|
+
* visitor → relay { t:'cmd', id, cmd, p? }
|
|
18
|
+
*
|
|
19
|
+
* Visitor command set lives in VISITOR_COMMANDS; 'visitor.hello' and
|
|
20
|
+
* 'visitor.gone' are relay-internal and never offered to the console.
|
|
21
|
+
*/
|
|
22
|
+
import { randomInt, randomUUID, createHash } from 'node:crypto'
|
|
23
|
+
|
|
24
|
+
export const PROTOCOL_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** Exact upgrade path where hosts dial the relay. */
|
|
27
|
+
export const HOST_UPGRADE_PATH = '/dsh-remote/host'
|
|
28
|
+
|
|
29
|
+
/** Owner panel JSON API, same-origin and behind the web GUI's session auth. */
|
|
30
|
+
export const OWNER_API_PATH = '/dsh-remote/api'
|
|
31
|
+
|
|
32
|
+
/** Commands the visitor console may issue. */
|
|
33
|
+
export const VISITOR_COMMANDS = new Set([
|
|
34
|
+
'host.info',
|
|
35
|
+
'sessions.list',
|
|
36
|
+
'sessions.state',
|
|
37
|
+
'sessions.create',
|
|
38
|
+
'sessions.send',
|
|
39
|
+
'sessions.cancel',
|
|
40
|
+
'sessions.observe',
|
|
41
|
+
'sessions.unobserve',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
/** Relay-internal commands bridged to the engine, invisible to the console. */
|
|
45
|
+
export const INTERNAL_COMMANDS = new Set(['visitor.hello', 'visitor.gone'])
|
|
46
|
+
|
|
47
|
+
/** An 8-digit code: 10000000..99999999, no leading zero. */
|
|
48
|
+
export function generateCode() {
|
|
49
|
+
return String(randomInt(10_000_000, 100_000_000))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isValidCode(code) {
|
|
53
|
+
return typeof code === 'string' && /^[1-9]\d{7}$/.test(code)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function generateId() {
|
|
57
|
+
return randomUUID()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Relay-side only: hosts carry plaintext tokens, the registry stores hashes. */
|
|
61
|
+
export function hashToken(token) {
|
|
62
|
+
return createHash('sha256').update(String(token)).digest('hex')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Safe JSON frame parse with a hard byte cap. */
|
|
66
|
+
export function parseFrame(text, maxBytes = 64 * 1024) {
|
|
67
|
+
if (typeof text !== 'string' || text.length > maxBytes) return null
|
|
68
|
+
try {
|
|
69
|
+
const frame = JSON.parse(text)
|
|
70
|
+
return frame && typeof frame === 'object' ? frame : null
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function nowSeconds() {
|
|
77
|
+
return Math.floor(Date.now() / 1000)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── GUI splice mode ────────────────────────────────────────────────────
|
|
81
|
+
*
|
|
82
|
+
* In `gui` mode the relay transparently mounts the host's real web GUI
|
|
83
|
+
* under `/<code>/`: HTTP requests and the GUI's single mux WebSocket
|
|
84
|
+
* (`/api/remote.mux`) are bridged frame-by-frame, the host side serving
|
|
85
|
+
* them from its own web server over loopback with an automatically minted
|
|
86
|
+
* owner session cookie.
|
|
87
|
+
*
|
|
88
|
+
* Frames (JSON text unless noted):
|
|
89
|
+
* relay → host { t:'hreq', id, m, p, h } begin HTTP proxy request
|
|
90
|
+
* { t:'hend', id } request body complete
|
|
91
|
+
* { t:'wopen', id, p, h } open spliced WebSocket
|
|
92
|
+
* { t:'wclose', id, code, reason }
|
|
93
|
+
* host → relay { t:'hres', id, s, h, sc } status, headers, set-cookie[]
|
|
94
|
+
* { t:'hfail', id, error }
|
|
95
|
+
* { t:'wok', id } / { t:'wfail', id, error }
|
|
96
|
+
* { t:'wclose', id, code, reason }
|
|
97
|
+
* both { t:'hchunk', id } (binary) request/response body chunk
|
|
98
|
+
* { t:'wdata', id } (binary) spliced socket bytes
|
|
99
|
+
*
|
|
100
|
+
* Binary envelope: `JSON(meta) 0x00 payload` — JSON never contains a raw
|
|
101
|
+
* NUL, so the first 0x00 byte separates header from payload.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
export function encodeBin(meta, payload) {
|
|
105
|
+
const header = Buffer.from(JSON.stringify(meta), 'utf8')
|
|
106
|
+
const body = payload === undefined ? EMPTY : Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
|
107
|
+
return Buffer.concat([header, SEPARATOR, body])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function decodeBin(buffer) {
|
|
111
|
+
const at = buffer.indexOf(0)
|
|
112
|
+
if (at === -1) return undefined
|
|
113
|
+
try {
|
|
114
|
+
const meta = JSON.parse(buffer.slice(0, at).toString('utf8'))
|
|
115
|
+
return meta && typeof meta === 'object' ? { meta, payload: buffer.slice(at + 1) } : undefined
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SEPARATOR = Buffer.from([0])
|
|
122
|
+
const EMPTY = Buffer.alloc(0)
|