dsh-computers 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/LICENSE +21 -0
- package/PROTOCOL.md +559 -0
- package/README.md +147 -2
- package/agent/README.md +276 -0
- package/agent/dsh-computers-agent.mjs +1993 -0
- package/client.js +3219 -0
- package/cordis.patch.yml +23 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +211 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +381 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +697 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +181 -0
- package/lib/tools.js +394 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/routes.js
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every HTTP route dsh-computers serves (PROTOCOL §3 and §4).
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces share `/api/dsh-computers`:
|
|
5
|
+
* - machine routes (`/agent/*`) authenticate with a bearer token and never
|
|
6
|
+
* pass the browser gate;
|
|
7
|
+
* - browser routes (state, keys, key-status, delete, rename) ask the host's
|
|
8
|
+
* request gate first, then answer JSON;
|
|
9
|
+
* - public routes (join.sh, join.ps1, agent.mjs, health) are unauthenticated
|
|
10
|
+
* but key- or path-scoped.
|
|
11
|
+
*
|
|
12
|
+
* Node builtins only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
HttpError, joinPs1, joinSh, jsonError, methodNotAllowed, readJsonBody,
|
|
17
|
+
refuseBrowserRequest, resolveBaseUrl, sendJson, sendText, serveAgentScript,
|
|
18
|
+
} from './http.js'
|
|
19
|
+
import {
|
|
20
|
+
bearerToken, isExpired, mintSecret, newComputerId, normalizeDisplayName,
|
|
21
|
+
optionalCount, optionalString, platformLabel, sha256Hex, tokenMatches,
|
|
22
|
+
} from './keys.js'
|
|
23
|
+
import { normalizeSessionId } from './selection.js'
|
|
24
|
+
|
|
25
|
+
/** Base path shared by every route (PROTOCOL §3). */
|
|
26
|
+
export const API_BASE = '/api/dsh-computers'
|
|
27
|
+
|
|
28
|
+
/** Enrollment bodies are small (PROTOCOL §3.1: max 16 KiB). */
|
|
29
|
+
const ENROLL_MAX_BYTES = 16 * 1024
|
|
30
|
+
|
|
31
|
+
/** Browser control bodies are tiny. */
|
|
32
|
+
const CONTROL_MAX_BYTES = 16 * 1024
|
|
33
|
+
|
|
34
|
+
/** Result posts carry two ≤1 MiB streams plus JSON escaping. */
|
|
35
|
+
const RESULT_MAX_BYTES = 8 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
/** Platform names an agent may report → the label the settings page shows. */
|
|
38
|
+
const PLATFORM_LABELS = new Map([
|
|
39
|
+
['macos', 'macOS'],
|
|
40
|
+
['darwin', 'macOS'],
|
|
41
|
+
['osx', 'macOS'],
|
|
42
|
+
['linux', 'Linux'],
|
|
43
|
+
['windows', 'Windows'],
|
|
44
|
+
['win32', 'Windows'],
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
/** Enforce one method; answers 405 and returns false otherwise. */
|
|
48
|
+
function requireMethod(req, res, ...methods) {
|
|
49
|
+
const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
|
|
50
|
+
if (method === 'HEAD' && methods.includes('GET')) return true
|
|
51
|
+
if (methods.includes(method)) return true
|
|
52
|
+
methodNotAllowed(res, methods.join(' / '))
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse an object body or throw the protocol's 400. */
|
|
57
|
+
async function readObjectBody(req, maxBytes) {
|
|
58
|
+
const body = await readJsonBody(req, maxBytes)
|
|
59
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
60
|
+
throw new HttpError(400, 'bad-request', 'request body must be a JSON object')
|
|
61
|
+
}
|
|
62
|
+
return body
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the exact-route registrar used by both route surfaces. Handlers own
|
|
67
|
+
* the whole response lifecycle, which is what lets the SSE route stay open.
|
|
68
|
+
*/
|
|
69
|
+
function createRouteRegistrar(ctx, onWarn) {
|
|
70
|
+
return (suffix, handler) => {
|
|
71
|
+
const path = `${API_BASE}${suffix}`
|
|
72
|
+
ctx.effect(() => ctx.webServer.register({
|
|
73
|
+
kind: 'exact',
|
|
74
|
+
path,
|
|
75
|
+
handler: async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
await handler(req, res)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (res.headersSent) {
|
|
80
|
+
try {
|
|
81
|
+
res.end()
|
|
82
|
+
} catch {
|
|
83
|
+
// Client already gone.
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (error instanceof HttpError) {
|
|
88
|
+
jsonError(res, error.status, error.code, error.message)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
onWarn(`dsh-computers: ${req.method} ${path} failed: ${error && error.stack ? error.stack : error}`)
|
|
92
|
+
jsonError(res, 500, 'internal', 'the dsh-computers host failed to handle this request')
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}), `dsh-computers: ${path}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register every route on the host web server.
|
|
101
|
+
* @param {object} options - route wiring.
|
|
102
|
+
* @param {object} options.ctx - Cordis context (webServer + optional connection).
|
|
103
|
+
* @param {object} options.config - resolved plugin config.
|
|
104
|
+
* @param {object} options.store - registry store.
|
|
105
|
+
* @param {object} options.hub - SSE/exec hub.
|
|
106
|
+
* @param {string} options.version - plugin version reported by /health.
|
|
107
|
+
* @param {URL} options.agentUrl - machine agent bundle location.
|
|
108
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
109
|
+
*/
|
|
110
|
+
export function createRoutes({ ctx, config, store, hub, selections, version, agentUrl, role = 'both', onWarn = () => {} }) {
|
|
111
|
+
/** Current clock as ISO text. */
|
|
112
|
+
const nowIso = () => new Date().toISOString()
|
|
113
|
+
/** Parsed request URL (pathname + query). */
|
|
114
|
+
const urlOf = (req) => new URL(typeof req.url === 'string' && req.url !== '' ? req.url : '/', 'http://dsh-computers.invalid')
|
|
115
|
+
/** Exact-route registrar shared with the runtime surface. */
|
|
116
|
+
const route = createRouteRegistrar(ctx, onWarn)
|
|
117
|
+
|
|
118
|
+
/** Ask the host's browser gate; true when the request was already refused. */
|
|
119
|
+
const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
|
|
120
|
+
|
|
121
|
+
/** Resolve the bearer token to a registry computer, or undefined. */
|
|
122
|
+
const authenticate = (req) => {
|
|
123
|
+
const token = bearerToken(req)
|
|
124
|
+
if (token === null) return undefined
|
|
125
|
+
for (const computer of store.read().computers) {
|
|
126
|
+
if (tokenMatches(token, computer.tokenHash)) return computer
|
|
127
|
+
}
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Refuse a machine route without valid credentials. */
|
|
132
|
+
const requireComputer = (req, res) => {
|
|
133
|
+
const computer = authenticate(req)
|
|
134
|
+
if (computer === undefined) {
|
|
135
|
+
jsonError(res, 401, 'invalid-token', 'authorization: Bearer <agent token> is required')
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
return computer
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The pending key the browser should show, if any. */
|
|
142
|
+
const currentPendingKey = (doc, nowMs) => {
|
|
143
|
+
for (let index = doc.pendingKeys.length - 1; index >= 0; index -= 1) {
|
|
144
|
+
const entry = doc.pendingKeys[index]
|
|
145
|
+
if (typeof entry.usedAt === 'string') continue
|
|
146
|
+
if (isExpired(entry.expiresAt, nowMs)) continue
|
|
147
|
+
return entry
|
|
148
|
+
}
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Protocol §4.1 projection of one registry record. */
|
|
153
|
+
const publicComputer = (computer) => {
|
|
154
|
+
const online = hub.isOnline(computer)
|
|
155
|
+
return {
|
|
156
|
+
id: computer.id,
|
|
157
|
+
name: computer.name,
|
|
158
|
+
hostname: computer.hostname,
|
|
159
|
+
os: computer.os,
|
|
160
|
+
platformLabel: computer.platformLabel,
|
|
161
|
+
online,
|
|
162
|
+
lastSeenAt: computer.lastSeenAt ?? null,
|
|
163
|
+
createdAt: computer.createdAt ?? null,
|
|
164
|
+
agentVersion: computer.agentVersion ?? null,
|
|
165
|
+
cpuModel: computer.cpuModel ?? null,
|
|
166
|
+
memoryBytes: computer.memoryBytes ?? null,
|
|
167
|
+
// Display sugar for the "1/1 Bot" badge (PROTOCOL §4.1).
|
|
168
|
+
bots: { used: online ? 1 : 0, total: 1 },
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Key-validated installer scripts share one refusal rule. */
|
|
173
|
+
const installerKey = (req, res, requiredSearchParam) => {
|
|
174
|
+
const keyValue = urlOf(req).searchParams.get(requiredSearchParam)
|
|
175
|
+
if (keyValue === null || keyValue === '') {
|
|
176
|
+
sendText(res, 410, 'missing enrollment key — open the Computers settings page and copy a fresh command\n')
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
const entry = store.read().pendingKeys.find((candidate) => candidate.key === keyValue)
|
|
180
|
+
if (entry === undefined) {
|
|
181
|
+
sendText(res, 410, 'unknown enrollment key — create a new one on the Computers settings page\n')
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
if (typeof entry.usedAt === 'string') {
|
|
185
|
+
sendText(res, 410, 'this enrollment key was already used — create a new one on the Computers settings page\n')
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
if (isExpired(entry.expiresAt, Date.now())) {
|
|
189
|
+
sendText(res, 410, 'this enrollment key expired — create a new one on the Computers settings page\n')
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
return entry
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Machine routes (bearer token, no browser gate)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
route('/agent/enroll', async (req, res) => {
|
|
200
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
201
|
+
const body = await readObjectBody(req, ENROLL_MAX_BYTES)
|
|
202
|
+
const keyValue = typeof body.key === 'string' ? body.key.trim() : ''
|
|
203
|
+
if (!/^dshc_[A-Za-z0-9_-]{20,}$/.test(keyValue)) {
|
|
204
|
+
throw new HttpError(400, 'bad-request', 'key must be a dshc_ enrollment key')
|
|
205
|
+
}
|
|
206
|
+
const nowMs = Date.now()
|
|
207
|
+
const doc = store.read()
|
|
208
|
+
const pendingKey = doc.pendingKeys.find((entry) => entry.key === keyValue)
|
|
209
|
+
// Order matters: an unknown key is 401, a consumed one 409, a stale one 410.
|
|
210
|
+
if (pendingKey === undefined) {
|
|
211
|
+
throw new HttpError(401, 'invalid-key', 'unknown enrollment key; create a new one on the Computers settings page')
|
|
212
|
+
}
|
|
213
|
+
if (typeof pendingKey.usedAt === 'string') {
|
|
214
|
+
throw new HttpError(409, 'key-used', 'this enrollment key was already used; create a new one')
|
|
215
|
+
}
|
|
216
|
+
if (isExpired(pendingKey.expiresAt, nowMs)) {
|
|
217
|
+
throw new HttpError(410, 'key-expired', 'this enrollment key expired; create a new one')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const machine = body.machine !== null && typeof body.machine === 'object' && !Array.isArray(body.machine)
|
|
221
|
+
? body.machine
|
|
222
|
+
: null
|
|
223
|
+
const hostname = optionalString(machine?.hostname, 255)
|
|
224
|
+
if (hostname === null) throw new HttpError(400, 'bad-request', 'machine.hostname is required')
|
|
225
|
+
const osName = optionalString(machine?.os, 64) ?? 'unknown'
|
|
226
|
+
const displayName = normalizeDisplayName(pendingKey.label)
|
|
227
|
+
?? normalizeDisplayName(optionalString(machine?.name, 255))
|
|
228
|
+
?? hostname
|
|
229
|
+
const requestedPlatform = optionalString(machine?.platform, 64)
|
|
230
|
+
const label = requestedPlatform !== null
|
|
231
|
+
? PLATFORM_LABELS.get(requestedPlatform.toLowerCase()) ?? platformLabel(osName)
|
|
232
|
+
: platformLabel(osName)
|
|
233
|
+
|
|
234
|
+
const fields = {
|
|
235
|
+
name: displayName,
|
|
236
|
+
hostname,
|
|
237
|
+
os: osName,
|
|
238
|
+
arch: optionalString(machine?.arch, 64) ?? 'unknown',
|
|
239
|
+
platformLabel: label,
|
|
240
|
+
cpuModel: optionalString(machine?.cpuModel, 256) ?? null,
|
|
241
|
+
memoryBytes: optionalCount(machine?.memoryBytes) ?? null,
|
|
242
|
+
nodeVersion: optionalString(machine?.nodeVersion, 64) ?? null,
|
|
243
|
+
agentVersion: optionalString(body.agentVersion, 64) ?? optionalString(machine?.agentVersion, 64) ?? 'unknown',
|
|
244
|
+
}
|
|
245
|
+
const token = mintSecret()
|
|
246
|
+
const tokenHash = sha256Hex(token)
|
|
247
|
+
// Re-enroll in place only when the key asked for it (PROTOCOL §3.1).
|
|
248
|
+
const replaceTarget = pendingKey.replace === true
|
|
249
|
+
? doc.computers.find((computer) => computer.hostname === hostname
|
|
250
|
+
|| computer.name.toLowerCase() === displayName.toLowerCase())
|
|
251
|
+
: undefined
|
|
252
|
+
|
|
253
|
+
const enrolled = store.mutate((document) => {
|
|
254
|
+
const keyEntry = document.pendingKeys.find((entry) => entry.key === keyValue)
|
|
255
|
+
if (keyEntry === undefined) {
|
|
256
|
+
throw new HttpError(401, 'invalid-key', 'unknown enrollment key; create a new one on the Computers settings page')
|
|
257
|
+
}
|
|
258
|
+
let computer
|
|
259
|
+
if (replaceTarget !== undefined) {
|
|
260
|
+
computer = document.computers.find((entry) => entry.id === replaceTarget.id)
|
|
261
|
+
}
|
|
262
|
+
if (computer !== undefined) {
|
|
263
|
+
// Same id, same name unless the agent reported a new one: the old
|
|
264
|
+
// token hash is replaced so the previous install stops working.
|
|
265
|
+
Object.assign(computer, fields, { tokenHash, lastSeenAt: nowIso() })
|
|
266
|
+
} else {
|
|
267
|
+
computer = {
|
|
268
|
+
id: newComputerId(),
|
|
269
|
+
...fields,
|
|
270
|
+
tokenHash,
|
|
271
|
+
createdAt: nowIso(),
|
|
272
|
+
lastSeenAt: nowIso(),
|
|
273
|
+
}
|
|
274
|
+
document.computers.push(computer)
|
|
275
|
+
}
|
|
276
|
+
keyEntry.usedAt = nowIso()
|
|
277
|
+
keyEntry.computerId = computer.id
|
|
278
|
+
return { computerId: computer.id, name: computer.name, replaced: replaceTarget !== undefined }
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (enrolled.replaced) {
|
|
282
|
+
// The old agent's token no longer authenticates; drop its session.
|
|
283
|
+
hub.closeStream(enrolled.computerId)
|
|
284
|
+
}
|
|
285
|
+
sendJson(res, 200, {
|
|
286
|
+
computerId: enrolled.computerId,
|
|
287
|
+
token,
|
|
288
|
+
name: enrolled.name,
|
|
289
|
+
serverUrl: resolveBaseUrl({ ctx, config, req }),
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
route('/agent/events', async (req, res) => {
|
|
294
|
+
// Authenticate before anything else on machine routes (PROTOCOL §3).
|
|
295
|
+
const computer = requireComputer(req, res)
|
|
296
|
+
if (computer === undefined) return
|
|
297
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
298
|
+
store.touch(computer.id)
|
|
299
|
+
hub.openStream(computer.id, computer.name, req, res)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
route('/agent/results', async (req, res) => {
|
|
303
|
+
// Authenticate before anything else on machine routes (PROTOCOL §3).
|
|
304
|
+
const computer = requireComputer(req, res)
|
|
305
|
+
if (computer === undefined) return
|
|
306
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
307
|
+
const body = await readObjectBody(req, RESULT_MAX_BYTES)
|
|
308
|
+
store.touch(computer.id)
|
|
309
|
+
if (!hub.settle(computer.id, body)) {
|
|
310
|
+
// The server already settled/timed out this request (PROTOCOL §3.3);
|
|
311
|
+
// the agent ignores this answer.
|
|
312
|
+
jsonError(res, 404, 'unknown-request', 'this execution is no longer pending (already settled or timed out)')
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
sendJson(res, 200, { ok: true })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
route('/agent/heartbeat', async (req, res) => {
|
|
319
|
+
// Authenticate before anything else on machine routes (PROTOCOL §3).
|
|
320
|
+
const computer = requireComputer(req, res)
|
|
321
|
+
if (computer === undefined) return
|
|
322
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
323
|
+
let body = {}
|
|
324
|
+
try {
|
|
325
|
+
body = await readObjectBody(req, 64 * 1024)
|
|
326
|
+
} catch (error) {
|
|
327
|
+
// An empty heartbeat body is legal; anything else is still a 400.
|
|
328
|
+
if (!(error instanceof HttpError) || error.code !== 'bad-request' || !/empty/.test(error.message)) throw error
|
|
329
|
+
}
|
|
330
|
+
const meta = body.meta !== null && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : null
|
|
331
|
+
const name = store.mutate((document) => {
|
|
332
|
+
const record = document.computers.find((entry) => entry.id === computer.id)
|
|
333
|
+
if (record === undefined) return computer.name
|
|
334
|
+
record.lastSeenAt = nowIso()
|
|
335
|
+
if (meta !== null) {
|
|
336
|
+
const cpuModel = optionalString(meta.cpuModel, 256)
|
|
337
|
+
const agentVersion = optionalString(meta.agentVersion, 64)
|
|
338
|
+
const memoryBytes = optionalCount(meta.memoryBytes)
|
|
339
|
+
if (cpuModel !== null) record.cpuModel = cpuModel
|
|
340
|
+
if (agentVersion !== null) record.agentVersion = agentVersion
|
|
341
|
+
if (memoryBytes !== null) record.memoryBytes = memoryBytes
|
|
342
|
+
}
|
|
343
|
+
return record.name
|
|
344
|
+
})
|
|
345
|
+
sendJson(res, 200, { ok: true, name })
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Browser routes (host request gate first)
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
route('/state', async (req, res) => {
|
|
353
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
354
|
+
if (refuseBrowser(req, res)) return
|
|
355
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
356
|
+
const doc = store.read()
|
|
357
|
+
const pendingKey = currentPendingKey(doc, Date.now())
|
|
358
|
+
sendJson(res, 200, {
|
|
359
|
+
version: 2,
|
|
360
|
+
role,
|
|
361
|
+
serverUrl: resolveBaseUrl({ ctx, config, req }),
|
|
362
|
+
heartbeatMs: config.heartbeatMs,
|
|
363
|
+
computers: doc.computers.map(publicComputer),
|
|
364
|
+
pendingKey: pendingKey === null
|
|
365
|
+
? null
|
|
366
|
+
: { key: pendingKey.key, expiresAt: pendingKey.expiresAt ?? null, os: pendingKey.os ?? 'macos' },
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
route('/keys', async (req, res) => {
|
|
371
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
372
|
+
if (refuseBrowser(req, res)) return
|
|
373
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
374
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
375
|
+
const osName = typeof body.os === 'string' ? body.os.trim().toLowerCase() : 'macos'
|
|
376
|
+
if (!['macos', 'windows', 'linux'].includes(osName)) {
|
|
377
|
+
throw new HttpError(400, 'bad-request', 'os must be one of macos, windows, linux')
|
|
378
|
+
}
|
|
379
|
+
let label = null
|
|
380
|
+
if (body.label !== undefined && body.label !== null) {
|
|
381
|
+
label = normalizeDisplayName(body.label)
|
|
382
|
+
if (label === null) throw new HttpError(400, 'bad-request', 'label must be 1-64 visible characters')
|
|
383
|
+
}
|
|
384
|
+
const replace = body.replace === true
|
|
385
|
+
const createdAt = nowIso()
|
|
386
|
+
const expiresAt = new Date(Date.now() + config.keyTtlMinutes * 60_000).toISOString()
|
|
387
|
+
const keyValue = mintSecret()
|
|
388
|
+
|
|
389
|
+
store.mutate((document) => {
|
|
390
|
+
// Only one pending key at a time: creating a new one invalidates the
|
|
391
|
+
// earlier ones (PROTOCOL §4.2).
|
|
392
|
+
for (const entry of document.pendingKeys) {
|
|
393
|
+
if (typeof entry.usedAt !== 'string') entry.usedAt = createdAt
|
|
394
|
+
}
|
|
395
|
+
document.pendingKeys.push({
|
|
396
|
+
key: keyValue,
|
|
397
|
+
createdAt,
|
|
398
|
+
expiresAt,
|
|
399
|
+
os: osName,
|
|
400
|
+
label,
|
|
401
|
+
usedAt: null,
|
|
402
|
+
computerId: null,
|
|
403
|
+
replace,
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
const serverUrl = resolveBaseUrl({ ctx, config, req })
|
|
408
|
+
const installUrl = `${serverUrl}${API_BASE}/join.sh?key=${encodeURIComponent(keyValue)}`
|
|
409
|
+
const windowsUrl = `${serverUrl}${API_BASE}/join.ps1?key=${encodeURIComponent(keyValue)}`
|
|
410
|
+
sendJson(res, 200, {
|
|
411
|
+
key: keyValue,
|
|
412
|
+
os: osName,
|
|
413
|
+
expiresAt,
|
|
414
|
+
serverUrl,
|
|
415
|
+
command: `curl -fsSL '${installUrl}' | sh`,
|
|
416
|
+
windowsCommand: `irm '${windowsUrl}' | iex`,
|
|
417
|
+
installUrl,
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
route('/key-status', async (req, res) => {
|
|
422
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
423
|
+
if (refuseBrowser(req, res)) return
|
|
424
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
425
|
+
const keyValue = urlOf(req).searchParams.get('key')
|
|
426
|
+
const doc = store.read()
|
|
427
|
+
const entry = keyValue === null
|
|
428
|
+
? undefined
|
|
429
|
+
: doc.pendingKeys.find((candidate) => candidate.key === keyValue)
|
|
430
|
+
let status = 'unknown'
|
|
431
|
+
let computer = null
|
|
432
|
+
if (entry !== undefined) {
|
|
433
|
+
const linked = typeof entry.computerId === 'string'
|
|
434
|
+
? doc.computers.find((candidate) => candidate.id === entry.computerId)
|
|
435
|
+
: undefined
|
|
436
|
+
if (linked !== undefined) {
|
|
437
|
+
status = 'connected'
|
|
438
|
+
computer = { id: linked.id, name: linked.name, online: hub.isOnline(linked) }
|
|
439
|
+
} else if (typeof entry.usedAt === 'string') {
|
|
440
|
+
status = 'used'
|
|
441
|
+
} else if (isExpired(entry.expiresAt, Date.now())) {
|
|
442
|
+
status = 'expired'
|
|
443
|
+
} else {
|
|
444
|
+
status = 'waiting'
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
sendJson(res, 200, { status, computer })
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
route('/delete', async (req, res) => {
|
|
451
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
452
|
+
if (refuseBrowser(req, res)) return
|
|
453
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
454
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
455
|
+
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
456
|
+
if (id === '') throw new HttpError(400, 'bad-request', 'id is required')
|
|
457
|
+
const found = store.read().computers.some((computer) => computer.id === id)
|
|
458
|
+
if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
|
|
459
|
+
hub.closeStream(id)
|
|
460
|
+
store.mutate((document) => {
|
|
461
|
+
document.computers = document.computers.filter((computer) => computer.id !== id)
|
|
462
|
+
})
|
|
463
|
+
sendJson(res, 200, { ok: true })
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
route('/rename', async (req, res) => {
|
|
467
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
468
|
+
if (refuseBrowser(req, res)) return
|
|
469
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
470
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
471
|
+
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
472
|
+
if (id === '') throw new HttpError(400, 'bad-request', 'id is required')
|
|
473
|
+
const name = normalizeDisplayName(body.name)
|
|
474
|
+
if (name === null) throw new HttpError(400, 'bad-request', 'name must be 1-64 visible characters')
|
|
475
|
+
const found = store.read().computers.some((computer) => computer.id === id)
|
|
476
|
+
if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
|
|
477
|
+
store.mutate((document) => {
|
|
478
|
+
const record = document.computers.find((computer) => computer.id === id)
|
|
479
|
+
if (record !== undefined) record.name = name
|
|
480
|
+
})
|
|
481
|
+
// Optional `config` event (PROTOCOL §3.2): the agent picks up the new name.
|
|
482
|
+
hub.send(id, 'config', { name, execEnabled: config.execEnabled })
|
|
483
|
+
sendJson(res, 200, { ok: true, name })
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Per-session target selection (task-9)
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Protocol shape shared by GET and POST /selection. A stored id whose
|
|
492
|
+
* computer no longer exists is reported as "no selection" so the client can
|
|
493
|
+
* render the unselected state instead of a dangling target.
|
|
494
|
+
*/
|
|
495
|
+
const selectionPayload = (sessionId, selectedId) => {
|
|
496
|
+
const computer = selectedId === null
|
|
497
|
+
? undefined
|
|
498
|
+
: store.read().computers.find((candidate) => candidate.id === selectedId)
|
|
499
|
+
return {
|
|
500
|
+
sessionId,
|
|
501
|
+
computerId: computer === undefined ? null : computer.id,
|
|
502
|
+
computer: computer === undefined
|
|
503
|
+
? null
|
|
504
|
+
: {
|
|
505
|
+
id: computer.id,
|
|
506
|
+
name: computer.name,
|
|
507
|
+
online: hub.isOnline(computer),
|
|
508
|
+
platformLabel: computer.platformLabel ?? 'Unknown',
|
|
509
|
+
},
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
route('/selection', async (req, res) => {
|
|
514
|
+
// The host's request gate runs first on every browser route (PROTOCOL §4).
|
|
515
|
+
if (refuseBrowser(req, res)) return
|
|
516
|
+
const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
|
|
517
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
518
|
+
const sessionId = normalizeSessionId(urlOf(req).searchParams.get('sessionId'))
|
|
519
|
+
if (sessionId === null) {
|
|
520
|
+
throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
|
|
521
|
+
}
|
|
522
|
+
sendJson(res, 200, selectionPayload(sessionId, selections.get(sessionId)))
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
if (method === 'POST') {
|
|
526
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
527
|
+
const sessionId = normalizeSessionId(body.sessionId)
|
|
528
|
+
if (sessionId === null) {
|
|
529
|
+
throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
|
|
530
|
+
}
|
|
531
|
+
if (!Object.hasOwn(body, 'computerId')) {
|
|
532
|
+
throw new HttpError(400, 'bad-request', 'computerId is required (a computer id or null)')
|
|
533
|
+
}
|
|
534
|
+
if (body.computerId === null) {
|
|
535
|
+
selections.clear(sessionId)
|
|
536
|
+
sendJson(res, 200, selectionPayload(sessionId, null))
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
if (typeof body.computerId !== 'string' || body.computerId.trim() === '') {
|
|
540
|
+
throw new HttpError(400, 'bad-request', 'computerId must be a non-empty string or null')
|
|
541
|
+
}
|
|
542
|
+
const computerId = body.computerId.trim()
|
|
543
|
+
const known = store.read().computers.some((candidate) => candidate.id === computerId)
|
|
544
|
+
if (!known) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(computerId)}`)
|
|
545
|
+
selections.set(sessionId, computerId)
|
|
546
|
+
sendJson(res, 200, selectionPayload(sessionId, computerId))
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
methodNotAllowed(res, 'GET / POST')
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
// Public routes
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
route('/join.sh', async (req, res) => {
|
|
557
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
558
|
+
const entry = installerKey(req, res, 'key')
|
|
559
|
+
if (entry === null) return
|
|
560
|
+
const serverUrl = resolveBaseUrl({ ctx, config, req })
|
|
561
|
+
sendText(res, 200, joinSh(serverUrl, entry.key), 'text/x-shellscript; charset=utf-8')
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
route('/join.ps1', async (req, res) => {
|
|
565
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
566
|
+
const entry = installerKey(req, res, 'key')
|
|
567
|
+
if (entry === null) return
|
|
568
|
+
const serverUrl = resolveBaseUrl({ ctx, config, req })
|
|
569
|
+
sendText(res, 200, joinPs1(serverUrl, entry.key), 'text/plain; charset=utf-8')
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
route('/agent.mjs', async (req, res) => {
|
|
573
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
574
|
+
await serveAgentScript(res, agentUrl)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
route('/health', async (req, res) => {
|
|
578
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
579
|
+
const computers = store.read().computers
|
|
580
|
+
sendJson(res, 200, {
|
|
581
|
+
ok: true,
|
|
582
|
+
version,
|
|
583
|
+
computers: computers.length,
|
|
584
|
+
online: hub.onlineCount(),
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Register the runtime-mode routes (PROTOCOL §10.5) on the host web server.
|
|
591
|
+
*
|
|
592
|
+
* These are browser-gated exactly like §4 and every answer carries the full
|
|
593
|
+
* §10.3 status object (plus `role`, so a pure `role: 'runtime'` deployment —
|
|
594
|
+
* which registers no `/state` route — can still tell the client what to render).
|
|
595
|
+
*
|
|
596
|
+
* @param {object} options - route wiring.
|
|
597
|
+
* @param {object} options.ctx - Cordis context.
|
|
598
|
+
* @param {object} options.config - resolved plugin config.
|
|
599
|
+
* @param {object} options.runtime - runtime manager (lib/runtime.js).
|
|
600
|
+
* @param {string} options.role - 'hub' | 'runtime' | 'both' (echoed in answers).
|
|
601
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
602
|
+
*/
|
|
603
|
+
export function createRuntimeRoutes({ ctx, config, runtime, role = 'both', onWarn = () => {} }) {
|
|
604
|
+
const route = createRouteRegistrar(ctx, onWarn)
|
|
605
|
+
const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
|
|
606
|
+
/** Every answer is the status object plus the role the client is talking to. */
|
|
607
|
+
const respond = (res, status) => sendJson(res, 200, { ...status, role })
|
|
608
|
+
|
|
609
|
+
route('/runtime', async (req, res) => {
|
|
610
|
+
if (refuseBrowser(req, res)) return
|
|
611
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
612
|
+
respond(res, runtime.status())
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
route('/runtime/connect', async (req, res) => {
|
|
616
|
+
if (refuseBrowser(req, res)) return
|
|
617
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
618
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
619
|
+
const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim() : ''
|
|
620
|
+
if (serverUrl === '') throw new HttpError(400, 'bad-request', 'serverUrl is required')
|
|
621
|
+
// The client may send `key` (spec) or `enrollmentKey` (older drafts).
|
|
622
|
+
const key = typeof body.key === 'string'
|
|
623
|
+
? body.key.trim()
|
|
624
|
+
: typeof body.enrollmentKey === 'string'
|
|
625
|
+
? body.enrollmentKey.trim()
|
|
626
|
+
: ''
|
|
627
|
+
if (key === '') throw new HttpError(400, 'bad-request', 'key is required')
|
|
628
|
+
// configure() validates the URL, enrolls against the hub and maps the hub's
|
|
629
|
+
// 401/409/410 onto the protocol's invalid-key/key-used/key-expired codes;
|
|
630
|
+
// a transport failure becomes 502 hub-unreachable.
|
|
631
|
+
respond(res, await runtime.configure({ serverUrl, key }))
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
route('/runtime/enable', async (req, res) => {
|
|
635
|
+
if (refuseBrowser(req, res)) return
|
|
636
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
637
|
+
const body = await readObjectBody(req, CONTROL_MAX_BYTES)
|
|
638
|
+
if (typeof body.enabled !== 'boolean') {
|
|
639
|
+
throw new HttpError(400, 'bad-request', 'enabled must be a boolean')
|
|
640
|
+
}
|
|
641
|
+
respond(res, await runtime.enable(body.enabled))
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
route('/runtime/disconnect', async (req, res) => {
|
|
645
|
+
if (refuseBrowser(req, res)) return
|
|
646
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
647
|
+
// Body is optional on disconnect: nothing to read, nothing to validate.
|
|
648
|
+
// Drain it anyway so keep-alive sockets are not reset between requests.
|
|
649
|
+
try {
|
|
650
|
+
req.resume()
|
|
651
|
+
} catch {
|
|
652
|
+
// Nothing to drain.
|
|
653
|
+
}
|
|
654
|
+
respond(res, await runtime.disconnect())
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Register the minimal read-only routes a `role: 'runtime'` deployment still
|
|
660
|
+
* needs (PROTOCOL §10.1, client contract in §10.6).
|
|
661
|
+
*
|
|
662
|
+
* A runtime is not a controller, so the hub API stays unregistered — no
|
|
663
|
+
* `/agent/*`, no keys/join scripts, no served agent bundle. But the settings
|
|
664
|
+
* client learns its role from `GET /state`; without it the page can only show
|
|
665
|
+
* a load-error card. `/state` therefore answers with `role` and an empty
|
|
666
|
+
* computer list (a runtime owns no registry), and `/health` stays for probes.
|
|
667
|
+
*
|
|
668
|
+
* @param {object} options - route wiring.
|
|
669
|
+
* @param {object} options.ctx - Cordis context.
|
|
670
|
+
* @param {object} options.config - resolved plugin config.
|
|
671
|
+
* @param {string} options.role - always 'runtime' here; echoed in /state.
|
|
672
|
+
* @param {string} options.version - plugin version reported by /health.
|
|
673
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
674
|
+
*/
|
|
675
|
+
export function createRuntimeStatusRoutes({ ctx, config, role = 'runtime', version, onWarn = () => {} }) {
|
|
676
|
+
const route = createRouteRegistrar(ctx, onWarn)
|
|
677
|
+
const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
|
|
678
|
+
|
|
679
|
+
route('/state', async (req, res) => {
|
|
680
|
+
if (refuseBrowser(req, res)) return
|
|
681
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
682
|
+
sendJson(res, 200, {
|
|
683
|
+
version: 2,
|
|
684
|
+
role,
|
|
685
|
+
serverUrl: resolveBaseUrl({ ctx, config, req }),
|
|
686
|
+
heartbeatMs: config.heartbeatMs,
|
|
687
|
+
// This deployment only follows a hub; it has no computer registry.
|
|
688
|
+
computers: [],
|
|
689
|
+
pendingKey: null,
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
route('/health', async (req, res) => {
|
|
694
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
695
|
+
sendJson(res, 200, { ok: true, version, computers: 0, online: 0 })
|
|
696
|
+
})
|
|
697
|
+
}
|