dsh-computers 0.0.0-stage → 1.3.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 +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/http.js
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small HTTP helpers shared by every dsh-computers host route: JSON/text
|
|
3
|
+
* writers, a bounded JSON body reader, the browser request gate, the origin
|
|
4
|
+
* resolver and the two installer script generators.
|
|
5
|
+
*
|
|
6
|
+
* Node builtins only — this module has to load in any DSH profile.
|
|
7
|
+
*
|
|
8
|
+
* The gate is a compact restatement of `dshmarket/src/http.ts` (sendJson,
|
|
9
|
+
* refuseUnadmitted, sameOrigin, loopbackAuthority, trustedAuthority). The
|
|
10
|
+
* reason it exists is the same: `webServer.register` routes are exact
|
|
11
|
+
* registrations on the bare web server, so they never pass DSH's own `/api`
|
|
12
|
+
* fence and each plugin has to ask the host's fence itself.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises'
|
|
16
|
+
import { request as httpRequest } from 'node:http'
|
|
17
|
+
import { request as httpsRequest } from 'node:https'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One request failure that maps directly onto an HTTP response.
|
|
21
|
+
* Route handlers throw these; the route wrapper turns them into JSON.
|
|
22
|
+
*/
|
|
23
|
+
export class HttpError extends Error {
|
|
24
|
+
/**
|
|
25
|
+
* @param {number} status - HTTP status code.
|
|
26
|
+
* @param {string} code - stable machine-readable error code.
|
|
27
|
+
* @param {string} message - human-readable detail.
|
|
28
|
+
*/
|
|
29
|
+
constructor(status, code, message) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = 'HttpError'
|
|
32
|
+
this.status = status
|
|
33
|
+
this.code = code
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Write one JSON payload. `no-store` because every answer here is live state. */
|
|
38
|
+
export function sendJson(res, status, payload) {
|
|
39
|
+
if (res.headersSent) {
|
|
40
|
+
res.end()
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const body = JSON.stringify(payload)
|
|
44
|
+
res.writeHead(status, {
|
|
45
|
+
'content-type': 'application/json; charset=utf-8',
|
|
46
|
+
'cache-control': 'no-store',
|
|
47
|
+
'content-length': Buffer.byteLength(body),
|
|
48
|
+
})
|
|
49
|
+
res.end(body)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Write one plain-text payload (installer scripts, exit refusals). */
|
|
53
|
+
export function sendText(res, status, text, contentType = 'text/plain; charset=utf-8') {
|
|
54
|
+
if (res.headersSent) {
|
|
55
|
+
res.end()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const body = String(text)
|
|
59
|
+
res.writeHead(status, {
|
|
60
|
+
'content-type': contentType,
|
|
61
|
+
'cache-control': 'no-store',
|
|
62
|
+
'content-length': Buffer.byteLength(body),
|
|
63
|
+
})
|
|
64
|
+
res.end(body)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One protocol-shaped `{ error, message }` failure. */
|
|
68
|
+
export function jsonError(res, status, code, message) {
|
|
69
|
+
sendJson(res, status, { error: code, message })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 405 with the single method the route supports. */
|
|
73
|
+
export function methodNotAllowed(res, allow) {
|
|
74
|
+
if (!res.headersSent) res.setHeader('allow', allow)
|
|
75
|
+
sendJson(res, 405, { error: 'method-not-allowed', message: `use ${allow}` })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read a JSON request body, refusing anything above `maxBytes`.
|
|
80
|
+
* Reads the stream to its end (or drains it on refusal) so the client sees a
|
|
81
|
+
* response instead of a cut socket.
|
|
82
|
+
* @param {import('node:http').IncomingMessage} req - request to read.
|
|
83
|
+
* @param {number} maxBytes - inclusive size cap.
|
|
84
|
+
* @returns {Promise<unknown>} the parsed JSON value.
|
|
85
|
+
*/
|
|
86
|
+
export async function readJsonBody(req, maxBytes) {
|
|
87
|
+
const chunks = []
|
|
88
|
+
let size = 0
|
|
89
|
+
let overLimit = false
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
req.on('data', (chunk) => {
|
|
92
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
93
|
+
size += buffer.length
|
|
94
|
+
if (size > maxBytes) {
|
|
95
|
+
// Keep consuming (and forgetting) the rest so the client receives a
|
|
96
|
+
// readable 413 instead of a reset socket, without buffering it.
|
|
97
|
+
overLimit = true
|
|
98
|
+
chunks.length = 0
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
chunks.push(buffer)
|
|
102
|
+
})
|
|
103
|
+
req.on('end', resolve)
|
|
104
|
+
req.on('error', reject)
|
|
105
|
+
req.on('aborted', () => reject(new HttpError(400, 'bad-request', 'request aborted')))
|
|
106
|
+
})
|
|
107
|
+
if (overLimit) throw new HttpError(413, 'payload-too-large', `request body exceeds ${maxBytes} bytes`)
|
|
108
|
+
if (size === 0) throw new HttpError(400, 'bad-request', 'request body is empty')
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
111
|
+
} catch {
|
|
112
|
+
throw new HttpError(400, 'bad-request', 'request body is not valid JSON')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parse a bare `host[:port]` authority, or null when the value is not one. */
|
|
117
|
+
function parseAuthority(value) {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(`http://${value}`)
|
|
120
|
+
const canonical = url.port === '' ? url.hostname : `${url.hostname}:${url.port}`
|
|
121
|
+
return canonical === value.toLowerCase() ? url : null
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether a request authority is one of the deployment's declared ones.
|
|
129
|
+
* Port-less entries match the hostname on any port; entries with a port match
|
|
130
|
+
* that exact authority. Both sides compare through WHATWG normalization.
|
|
131
|
+
* @param {string} host - the request's `Host` header.
|
|
132
|
+
* @param {readonly string[]} trustedHosts - authorities the host declared.
|
|
133
|
+
* @returns {boolean} whether the authority is declared.
|
|
134
|
+
*/
|
|
135
|
+
export function trustedAuthority(host, trustedHosts) {
|
|
136
|
+
const hostUrl = parseAuthority(host)
|
|
137
|
+
if (hostUrl === null) return false
|
|
138
|
+
return trustedHosts.some((entry) => {
|
|
139
|
+
if (typeof entry !== 'string') return false
|
|
140
|
+
const entryUrl = parseAuthority(entry)
|
|
141
|
+
if (entryUrl === null) return false
|
|
142
|
+
return entryUrl.port === '' ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a `Host` header names a loopback authority. DNS rebinding is stopped
|
|
148
|
+
* here: Host is the one header an attacker's page cannot forge.
|
|
149
|
+
* @param {string | undefined} host - the request's `Host` header.
|
|
150
|
+
* @returns {boolean} whether it names loopback.
|
|
151
|
+
*/
|
|
152
|
+
export function loopbackAuthority(host) {
|
|
153
|
+
if (typeof host !== 'string') return false
|
|
154
|
+
const lower = host.toLowerCase()
|
|
155
|
+
// IPv6 literals keep their brackets; `[::1]:3080` → `[::1]`.
|
|
156
|
+
const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
|
|
157
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Same-origin check used only when the host publishes no request gate.
|
|
162
|
+
*
|
|
163
|
+
* Copied in spirit from `dshmarket/src/http.ts`:
|
|
164
|
+
* - an absent `Host` is allowed (a stripping proxy — the Desktop build's —
|
|
165
|
+
* removes it, and a page can never omit it);
|
|
166
|
+
* - a present `Host` must name loopback or a declared authority;
|
|
167
|
+
* - `sec-fetch-site: cross-site` is refused;
|
|
168
|
+
* - an absent `Origin` is allowed (non-browser clients can forge any Origin,
|
|
169
|
+
* and the Desktop proxy strips it);
|
|
170
|
+
* - a present Origin must share the request's Host.
|
|
171
|
+
* @param {import('node:http').IncomingMessage} req - request to judge.
|
|
172
|
+
* @param {readonly string[]} trustedHosts - host-declared authorities.
|
|
173
|
+
* @returns {boolean} whether the request may reach a browser route.
|
|
174
|
+
*/
|
|
175
|
+
export function sameOrigin(req, trustedHosts = []) {
|
|
176
|
+
const host = req.headers.host
|
|
177
|
+
if (host !== undefined && !loopbackAuthority(host) && !trustedAuthority(host, trustedHosts)) return false
|
|
178
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
179
|
+
const origin = req.headers.origin
|
|
180
|
+
if (origin === undefined) return true
|
|
181
|
+
try {
|
|
182
|
+
return new URL(origin).host === host
|
|
183
|
+
} catch {
|
|
184
|
+
// `Origin: null` and an empty Origin are present-but-unparseable: refused.
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The composition's `connection` service, if this host has one.
|
|
191
|
+
* Resolved per request: the host provides it only after its own async init.
|
|
192
|
+
* @param {object} ctx - Cordis context.
|
|
193
|
+
* @returns {object | undefined} the connection service.
|
|
194
|
+
*/
|
|
195
|
+
export function connectionOf(ctx) {
|
|
196
|
+
try {
|
|
197
|
+
const service = ctx.get('connection')
|
|
198
|
+
if (service !== undefined && service !== null) return service
|
|
199
|
+
} catch {
|
|
200
|
+
// fall through to the reflective read
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const service = Reflect.get(ctx, 'connection')
|
|
204
|
+
return service ?? undefined
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* POST one JSON body to a URL we dial ourselves (the hub's enroll route) and
|
|
212
|
+
* parse its JSON answer. Used only for outbound calls the plugin initiates.
|
|
213
|
+
*
|
|
214
|
+
* Any transport failure — DNS, refused connection, TLS, timeout, abort, an
|
|
215
|
+
* over-cap response — throws `HttpError(502, 'hub-unreachable', ...)`; an HTTP
|
|
216
|
+
* answer (whatever its status) resolves so the caller can map the hub's own
|
|
217
|
+
* error codes.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} urlString - absolute http(s) URL.
|
|
220
|
+
* @param {unknown} body - JSON-serializable request body.
|
|
221
|
+
* @param {object} [options] - call options.
|
|
222
|
+
* @param {number} [options.timeoutMs] - whole-exchange timeout (default 10 s).
|
|
223
|
+
* @param {AbortSignal} [options.signal] - caller cancellation.
|
|
224
|
+
* @param {number} [options.maxBytes] - response body cap (default 64 KiB).
|
|
225
|
+
* @returns {Promise<{ status: number, headers: Record<string, string | string[] | undefined>, text: string, data: unknown }>} the response.
|
|
226
|
+
*/
|
|
227
|
+
export async function postJson(urlString, body, { timeoutMs = 10_000, signal, maxBytes = 64 * 1024 } = {}) {
|
|
228
|
+
let url
|
|
229
|
+
try {
|
|
230
|
+
url = new URL(String(urlString))
|
|
231
|
+
} catch {
|
|
232
|
+
throw new HttpError(400, 'bad-request', `not a valid URL: ${JSON.stringify(urlString)}`)
|
|
233
|
+
}
|
|
234
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
235
|
+
throw new HttpError(400, 'bad-request', 'serverUrl must use http or https')
|
|
236
|
+
}
|
|
237
|
+
let payload
|
|
238
|
+
try {
|
|
239
|
+
payload = Buffer.from(JSON.stringify(body), 'utf8')
|
|
240
|
+
} catch {
|
|
241
|
+
throw new HttpError(400, 'bad-request', 'request body is not JSON-serializable')
|
|
242
|
+
}
|
|
243
|
+
if (signal?.aborted === true) throw new HttpError(502, 'hub-unreachable', 'the request was aborted')
|
|
244
|
+
|
|
245
|
+
return await new Promise((resolve, reject) => {
|
|
246
|
+
let settled = false
|
|
247
|
+
let timer = null
|
|
248
|
+
const finish = (error, value) => {
|
|
249
|
+
if (settled) return
|
|
250
|
+
settled = true
|
|
251
|
+
if (timer !== null) clearTimeout(timer)
|
|
252
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
253
|
+
if (error !== undefined) reject(error)
|
|
254
|
+
else resolve(value)
|
|
255
|
+
}
|
|
256
|
+
const onAbort = () => {
|
|
257
|
+
request.destroy(new Error('aborted'))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const transport = url.protocol === 'https:' ? httpsRequest : httpRequest
|
|
261
|
+
const request = transport(url, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: {
|
|
264
|
+
'content-type': 'application/json; charset=utf-8',
|
|
265
|
+
'content-length': payload.length,
|
|
266
|
+
accept: 'application/json',
|
|
267
|
+
},
|
|
268
|
+
}, (response) => {
|
|
269
|
+
const chunks = []
|
|
270
|
+
let size = 0
|
|
271
|
+
let overLimit = false
|
|
272
|
+
response.on('data', (chunk) => {
|
|
273
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
274
|
+
size += buffer.length
|
|
275
|
+
if (size > maxBytes) {
|
|
276
|
+
overLimit = true
|
|
277
|
+
chunks.length = 0
|
|
278
|
+
response.resume()
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
chunks.push(buffer)
|
|
282
|
+
})
|
|
283
|
+
response.on('end', () => {
|
|
284
|
+
if (overLimit) {
|
|
285
|
+
finish(new HttpError(502, 'hub-unreachable', `hub response exceeded ${maxBytes} bytes`))
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
289
|
+
let data
|
|
290
|
+
try {
|
|
291
|
+
data = text === '' ? undefined : JSON.parse(text)
|
|
292
|
+
} catch {
|
|
293
|
+
data = undefined
|
|
294
|
+
}
|
|
295
|
+
finish(undefined, { status: response.statusCode ?? 0, headers: response.headers, text, data })
|
|
296
|
+
})
|
|
297
|
+
response.on('error', (error) => finish(new HttpError(502, 'hub-unreachable', `hub response failed: ${error.message}`)))
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
request.on('error', (error) => {
|
|
301
|
+
// `request.destroy(new Error('aborted'))` also lands here; report the
|
|
302
|
+
// caller's cancellation rather than a raw socket error.
|
|
303
|
+
const message = signal?.aborted === true
|
|
304
|
+
? 'the request was aborted'
|
|
305
|
+
: `cannot reach the hub: ${error.message}`
|
|
306
|
+
finish(new HttpError(502, 'hub-unreachable', message))
|
|
307
|
+
})
|
|
308
|
+
timer = setTimeout(() => {
|
|
309
|
+
request.destroy(new Error('timeout'))
|
|
310
|
+
finish(new HttpError(502, 'hub-unreachable', `the hub did not answer within ${timeoutMs}ms`))
|
|
311
|
+
}, timeoutMs)
|
|
312
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
313
|
+
if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
|
|
314
|
+
request.end(payload)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Answer the request with the host's own rejection, if it has one; when the
|
|
320
|
+
* connection service is absent, apply the fallback same-origin rules.
|
|
321
|
+
* @param {object} ctx - Cordis context.
|
|
322
|
+
* @param {import('node:http').IncomingMessage} req - request to judge.
|
|
323
|
+
* @param {import('node:http').ServerResponse} res - response to answer on.
|
|
324
|
+
* @returns {boolean} true when the request was refused and answered here.
|
|
325
|
+
*/
|
|
326
|
+
export function refuseBrowserRequest(ctx, req, res) {
|
|
327
|
+
const connection = connectionOf(ctx)
|
|
328
|
+
const gate = connection?.requestRejection
|
|
329
|
+
if (typeof gate === 'function') {
|
|
330
|
+
let status
|
|
331
|
+
try {
|
|
332
|
+
status = gate.call(connection, req)
|
|
333
|
+
} catch {
|
|
334
|
+
// A gate that throws has not admitted anything: fail closed.
|
|
335
|
+
status = 403
|
|
336
|
+
}
|
|
337
|
+
if (typeof status === 'number') {
|
|
338
|
+
// Drain an unread request body so the refusal is not a socket reset.
|
|
339
|
+
try {
|
|
340
|
+
req.resume()
|
|
341
|
+
} catch {
|
|
342
|
+
// Nothing to drain.
|
|
343
|
+
}
|
|
344
|
+
jsonError(res, status, 'unadmitted', status === 401
|
|
345
|
+
? 'DSH login required: reopen the URL printed by dsh web at startup.'
|
|
346
|
+
: 'This request came from an untrusted origin and was refused.')
|
|
347
|
+
return true
|
|
348
|
+
}
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
const trustedHosts = Array.isArray(connection?.trustedHosts)
|
|
352
|
+
? connection.trustedHosts.filter((entry) => typeof entry === 'string')
|
|
353
|
+
: []
|
|
354
|
+
if (!sameOrigin(req, trustedHosts)) {
|
|
355
|
+
try {
|
|
356
|
+
req.resume()
|
|
357
|
+
} catch {
|
|
358
|
+
// Nothing to drain.
|
|
359
|
+
}
|
|
360
|
+
jsonError(res, 403, 'unadmitted', 'This request came from an untrusted origin and was refused.')
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Authority length cap: anything longer is not a host[:port], it is an attack. */
|
|
367
|
+
const MAX_AUTHORITY_CHARS = 255
|
|
368
|
+
|
|
369
|
+
/** One header as its first comma-separated value (Node joins duplicate headers). */
|
|
370
|
+
function firstHeaderValue(raw) {
|
|
371
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
372
|
+
if (typeof value !== 'string') return undefined
|
|
373
|
+
const first = value.split(',')[0]?.trim()
|
|
374
|
+
return first === undefined || first === '' ? undefined : first
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** One header as a single trimmed string (no comma splitting). */
|
|
378
|
+
function headerString(raw) {
|
|
379
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
380
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Normalize `proto` + `authority` into a bare origin, or null when the pair is
|
|
385
|
+
* not a clean one.
|
|
386
|
+
*
|
|
387
|
+
* Everything here is defensive: a proxy header arrives from outside and must
|
|
388
|
+
* never smuggle a scheme, path, credentials, control characters or a numeric
|
|
389
|
+
* shorthand past the WHATWG parser into a one-liner the user will paste into a
|
|
390
|
+
* shell. A rejected value returns null and the caller falls through.
|
|
391
|
+
* @param {unknown} proto - 'http' | 'https' (any case, trailing colon tolerated).
|
|
392
|
+
* @param {unknown} authority - bare `host[:port]` (IPv6 literals included).
|
|
393
|
+
* @returns {string | null} canonical `scheme://authority`, or null.
|
|
394
|
+
*/
|
|
395
|
+
function cleanOrigin(proto, authority) {
|
|
396
|
+
const scheme = typeof proto === 'string' ? proto.trim().toLowerCase().replace(/:$/, '') : ''
|
|
397
|
+
if (scheme !== 'http' && scheme !== 'https') return null
|
|
398
|
+
if (typeof authority !== 'string') return null
|
|
399
|
+
const host = authority.trim()
|
|
400
|
+
if (host === '' || host.length > MAX_AUTHORITY_CHARS) return null
|
|
401
|
+
// Reject whitespace and control characters, and the punctuation that would
|
|
402
|
+
// turn an authority into something else, BEFORE URL() can normalize it away.
|
|
403
|
+
if (/[\s\u0000-\u001f\u007f]/.test(host)) return null
|
|
404
|
+
if (host.includes('/') || host.includes('@') || host.includes('?') || host.includes('#')) return null
|
|
405
|
+
let url
|
|
406
|
+
try {
|
|
407
|
+
url = new URL(`${scheme}://${host}`)
|
|
408
|
+
} catch {
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
// A clean authority parses to exactly `/`, with no credentials and no query.
|
|
412
|
+
if (url.username !== '' || url.password !== '') return null
|
|
413
|
+
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return null
|
|
414
|
+
if (url.host === '') return null
|
|
415
|
+
// The authority must survive parsing unchanged apart from letter case and a
|
|
416
|
+
// redundant default port: `evil.com/x` or `a@b` must never be rewritten into
|
|
417
|
+
// a host, and `0` must not become `0.0.0.0`.
|
|
418
|
+
const canonicalHost = url.host.toLowerCase()
|
|
419
|
+
const given = host.toLowerCase()
|
|
420
|
+
if (given !== canonicalHost && given !== `${canonicalHost}:80` && given !== `${canonicalHost}:443`) return null
|
|
421
|
+
// `url.host` canonicalizes case, keeps IPv6 brackets and drops a default port.
|
|
422
|
+
return `${scheme}://${url.host}`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Split on `separator` while ignoring separators inside double-quoted strings. */
|
|
426
|
+
function splitUnquoted(value, separator) {
|
|
427
|
+
const parts = []
|
|
428
|
+
let start = 0
|
|
429
|
+
let quoted = false
|
|
430
|
+
let escaped = false
|
|
431
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
432
|
+
const char = value[index]
|
|
433
|
+
if (escaped) {
|
|
434
|
+
escaped = false
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
if (quoted && char === '\\') {
|
|
438
|
+
escaped = true
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
if (char === '"') {
|
|
442
|
+
quoted = !quoted
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
if (!quoted && char === separator) {
|
|
446
|
+
parts.push(value.slice(start, index))
|
|
447
|
+
start = index + 1
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
parts.push(value.slice(start))
|
|
451
|
+
return parts
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* `proto`/`host` of the FIRST RFC 7239 `Forwarded` element (PROTOCOL §4.
|
|
456
|
+
* "parse the FIRST element only"): later hops may describe a different proxy.
|
|
457
|
+
* @param {unknown} raw - the `forwarded` header value.
|
|
458
|
+
* @returns {{ proto?: string, host?: string }} lowercased keywords, raw values.
|
|
459
|
+
*/
|
|
460
|
+
function parseFirstForwarded(raw) {
|
|
461
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
462
|
+
if (typeof value !== 'string' || value.trim() === '' || value.length > 2048) return {}
|
|
463
|
+
const [first = ''] = splitUnquoted(value, ',')
|
|
464
|
+
const out = {}
|
|
465
|
+
for (const segment of splitUnquoted(first, ';')) {
|
|
466
|
+
const equals = segment.indexOf('=')
|
|
467
|
+
if (equals === -1) continue
|
|
468
|
+
const name = segment.slice(0, equals).trim().toLowerCase()
|
|
469
|
+
let pair = segment.slice(equals + 1).trim()
|
|
470
|
+
if (pair.startsWith('"')) {
|
|
471
|
+
if (pair.length < 2 || !pair.endsWith('"')) continue
|
|
472
|
+
pair = pair.slice(1, -1).replace(/\\(.)/g, '$1')
|
|
473
|
+
}
|
|
474
|
+
if (name === 'proto' || name === 'host') out[name] = pair
|
|
475
|
+
}
|
|
476
|
+
return out
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Protocol of the request's own `Origin` header when it names the SAME
|
|
481
|
+
* authority as the request's Host. A browser sends `Origin` on every POST, so
|
|
482
|
+
* an HTTPS reverse proxy that forgets `X-Forwarded-Proto` still gets `https://`
|
|
483
|
+
* as long as the browser's Origin agrees with the Host it forwarded.
|
|
484
|
+
* @param {unknown} rawOrigin - the `origin` header value.
|
|
485
|
+
* @param {string} ownHost - the request's `Host` header value.
|
|
486
|
+
* @returns {'http' | 'https' | undefined} the agreeing protocol, if any.
|
|
487
|
+
*/
|
|
488
|
+
function originProtocolFor(rawOrigin, ownHost) {
|
|
489
|
+
const value = headerString(rawOrigin)
|
|
490
|
+
if (value === undefined) return undefined
|
|
491
|
+
let url
|
|
492
|
+
try {
|
|
493
|
+
url = new URL(value)
|
|
494
|
+
} catch {
|
|
495
|
+
// `Origin: null` (sandboxed iframe, data: document) is not a vouch.
|
|
496
|
+
return undefined
|
|
497
|
+
}
|
|
498
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
|
499
|
+
const cleaned = cleanOrigin(url.protocol, ownHost)
|
|
500
|
+
if (cleaned === null) return undefined
|
|
501
|
+
try {
|
|
502
|
+
const own = new URL(cleaned)
|
|
503
|
+
return own.host.toLowerCase() === url.host.toLowerCase() ? url.protocol.slice(0, -1) : undefined
|
|
504
|
+
} catch {
|
|
505
|
+
return undefined
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The externally reachable base URL of this deployment: what the generated
|
|
511
|
+
* one-liner, the settings page and a joining machine should use.
|
|
512
|
+
*
|
|
513
|
+
* Auto-detection instead of a mandatory `publicUrl`: a deployment behind a
|
|
514
|
+
* reverse proxy is exactly the case where the browser's own origin is
|
|
515
|
+
* the address another machine can actually reach, so honoring the proxy's
|
|
516
|
+
* forwarded headers makes the command correct with zero configuration. These
|
|
517
|
+
* headers are trusted here only for *display/URL generation* — this is not an
|
|
518
|
+
* authorization decision (the browser routes run the host's request gate
|
|
519
|
+
* separately). An operator who does not trust their proxy sets `publicUrl`,
|
|
520
|
+
* which always wins.
|
|
521
|
+
*
|
|
522
|
+
* Precedence:
|
|
523
|
+
* 1. `config.publicUrl` — explicit operator override, used verbatim minus a
|
|
524
|
+
* trailing slash. It MAY carry a path prefix (sub-path deployment,
|
|
525
|
+
* e.g. `https://host/dsh`); auto-detected values are origin-only.
|
|
526
|
+
* 2. `x-forwarded-proto` + `x-forwarded-host` (first comma entry of each), or
|
|
527
|
+
* the first RFC 7239 `Forwarded` element (`proto=`/`host=`). The protocol
|
|
528
|
+
* may also come from `x-forwarded-ssl: on` (old nginx), but a forwarded
|
|
529
|
+
* host is only ever paired with a forwarded protocol, never the local one.
|
|
530
|
+
* 3. the request's own `Host` plus a protocol hint: forwarded proto /
|
|
531
|
+
* `x-forwarded-ssl`, else the `origin` header's protocol when its host
|
|
532
|
+
* equals the resolved authority, else `ctx.webServer.protocol`.
|
|
533
|
+
* 4. `ctx.webServer.getOrigin()` (throws before the listener binds).
|
|
534
|
+
* 5. `http://127.0.0.1:<ctx.webServer.port>` when known, else ''.
|
|
535
|
+
*
|
|
536
|
+
* Pure: no I/O, no caching; never throws.
|
|
537
|
+
* @param {object} options - resolution inputs.
|
|
538
|
+
* @param {object} [options.ctx] - Cordis context (webServer).
|
|
539
|
+
* @param {{ publicUrl?: string }} [options.config] - resolved plugin config.
|
|
540
|
+
* @param {import('node:http').IncomingMessage} [options.req] - the request being served.
|
|
541
|
+
* @returns {string} a base URL with no trailing slash, or '' when unknown.
|
|
542
|
+
*/
|
|
543
|
+
export function resolveBaseUrl({ ctx, config, req } = {}) {
|
|
544
|
+
// 1. Explicit operator override always wins. A path prefix is preserved
|
|
545
|
+
// verbatim (minus a trailing slash) so sub-path deployments work; callers
|
|
546
|
+
// concatenate `${base}${API_BASE}/...`, which never double-slashes.
|
|
547
|
+
const configured = typeof config?.publicUrl === 'string' ? config.publicUrl.trim() : ''
|
|
548
|
+
if (configured !== '') {
|
|
549
|
+
let url = null
|
|
550
|
+
try {
|
|
551
|
+
url = new URL(configured)
|
|
552
|
+
} catch {
|
|
553
|
+
url = null
|
|
554
|
+
}
|
|
555
|
+
if (url !== null && (url.protocol === 'http:' || url.protocol === 'https:')) {
|
|
556
|
+
// Keep the operator's path prefix (sub-path deployment) and canonicalize
|
|
557
|
+
// everything else: a base URL must not carry credentials, a query or a
|
|
558
|
+
// fragment, because they would be spliced into a shell one-liner.
|
|
559
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`
|
|
560
|
+
}
|
|
561
|
+
// A malformed publicUrl is the operator's bug; fall through to detection.
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const headers = req?.headers ?? {}
|
|
565
|
+
|
|
566
|
+
/** Forwarded protocol hint: `x-forwarded-proto` first, else `x-forwarded-ssl: on`. */
|
|
567
|
+
const forwardedProtoRaw = firstHeaderValue(headers['x-forwarded-proto'])
|
|
568
|
+
const forwardedProto = forwardedProtoRaw !== undefined && /^https?:?$/i.test(forwardedProtoRaw)
|
|
569
|
+
? forwardedProtoRaw.replace(/:$/, '').toLowerCase()
|
|
570
|
+
: undefined
|
|
571
|
+
const forwardedSsl = headerString(headers['x-forwarded-ssl'])
|
|
572
|
+
const sslHint = forwardedSsl !== undefined && forwardedSsl.toLowerCase() === 'on' ? 'https' : undefined
|
|
573
|
+
const protoHint = forwardedProto ?? sslHint
|
|
574
|
+
const forwardedHost = firstHeaderValue(headers['x-forwarded-host'])
|
|
575
|
+
|
|
576
|
+
// 2. A forwarded host is only paired with a forwarded protocol.
|
|
577
|
+
if (forwardedHost !== undefined && protoHint !== undefined) {
|
|
578
|
+
const pair = cleanOrigin(protoHint, forwardedHost)
|
|
579
|
+
if (pair !== null) return pair
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 2b. RFC 7239, FIRST element only (later hops may describe another proxy).
|
|
583
|
+
const rfc7239 = parseFirstForwarded(headers.forwarded)
|
|
584
|
+
if (rfc7239.proto !== undefined && rfc7239.host !== undefined) {
|
|
585
|
+
const element = cleanOrigin(rfc7239.proto, rfc7239.host)
|
|
586
|
+
if (element !== null) return element
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 3. The request's own Host plus a protocol hint. The hint order is what
|
|
590
|
+
// rescues an HTTPS proxy with no X-Forwarded-Proto: the browser's Origin
|
|
591
|
+
// still says https for the same authority.
|
|
592
|
+
const ownHost = headerString(headers.host)
|
|
593
|
+
if (ownHost !== undefined) {
|
|
594
|
+
const listenerProto = typeof ctx?.webServer?.protocol === 'string'
|
|
595
|
+
? ctx.webServer.protocol.replace(/:$/, '').toLowerCase()
|
|
596
|
+
: ''
|
|
597
|
+
const hint = protoHint
|
|
598
|
+
?? originProtocolFor(headers.origin, ownHost)
|
|
599
|
+
?? (listenerProto === 'http' || listenerProto === 'https' ? listenerProto : undefined)
|
|
600
|
+
const own = cleanOrigin(hint, ownHost)
|
|
601
|
+
if (own !== null) return own
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 4. The live bound origin.
|
|
605
|
+
try {
|
|
606
|
+
const origin = ctx?.webServer?.getOrigin?.()
|
|
607
|
+
if (typeof origin === 'string' && origin.trim() !== '') {
|
|
608
|
+
const url = new URL(origin.trim())
|
|
609
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') return url.origin
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// The origin is unreadable before the listener binds.
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 5. Last resort: the bound port on loopback, else unknown.
|
|
616
|
+
const port = ctx?.webServer?.port
|
|
617
|
+
if (Number.isSafeInteger(port) && port > 0 && port <= 65535) return `http://127.0.0.1:${port}`
|
|
618
|
+
return ''
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** POSIX single-quote escaping for a value embedded in a shell script. */
|
|
622
|
+
function shellQuote(value) {
|
|
623
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** PowerShell single-quote escaping. */
|
|
627
|
+
function psQuote(value) {
|
|
628
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The macOS/Linux one-liner installer behind `GET /join.sh` (PROTOCOL §4.6).
|
|
633
|
+
* Generated from the same server URL and key the settings page shows, so the
|
|
634
|
+
* script can never drift from the routes.
|
|
635
|
+
* @param {string} serverUrl - host origin.
|
|
636
|
+
* @param {string} key - enrollment key.
|
|
637
|
+
* @returns {string} a POSIX shell script.
|
|
638
|
+
*/
|
|
639
|
+
export function joinSh(serverUrl, key) {
|
|
640
|
+
const server = shellQuote(serverUrl)
|
|
641
|
+
const token = shellQuote(key)
|
|
642
|
+
// Kept as a line array instead of one template literal so every `$` below
|
|
643
|
+
// stays a shell variable and only `server`/`token` interpolate at build time.
|
|
644
|
+
return [
|
|
645
|
+
'#!/bin/sh',
|
|
646
|
+
'# dsh-computers: connect this machine to a DSH Web deployment.',
|
|
647
|
+
'set -eu',
|
|
648
|
+
'',
|
|
649
|
+
`SERVER=${server}`,
|
|
650
|
+
`KEY=${token}`,
|
|
651
|
+
'DIR="$HOME/.dsh-computers"',
|
|
652
|
+
'AGENT="$DIR/agent.mjs"',
|
|
653
|
+
'',
|
|
654
|
+
'say() { printf \'%s\\n\' "$*"; }',
|
|
655
|
+
'die() { printf \'error: %s\\n\' "$*" >&2; exit 1; }',
|
|
656
|
+
'',
|
|
657
|
+
'command -v curl >/dev/null 2>&1 || die "curl is required to download the agent."',
|
|
658
|
+
'command -v node >/dev/null 2>&1 || die "Node.js 22 or newer is required: https://nodejs.org"',
|
|
659
|
+
'NODE_VERSION="$(node -v 2>/dev/null || true)"',
|
|
660
|
+
'NODE_MAJOR="$(printf \'%s\' "$NODE_VERSION" | sed \'s/^v//\' | cut -d. -f1)"',
|
|
661
|
+
'case "$NODE_MAJOR" in',
|
|
662
|
+
' \'\'|*[!0-9]*) die "cannot determine the Node.js version (node -v printed \'$NODE_VERSION\')." ;;',
|
|
663
|
+
'esac',
|
|
664
|
+
'if [ "$NODE_MAJOR" -lt 22 ]; then',
|
|
665
|
+
' die "Node.js 22 or newer is required, found $NODE_VERSION."',
|
|
666
|
+
'fi',
|
|
667
|
+
'',
|
|
668
|
+
'mkdir -p "$DIR"',
|
|
669
|
+
'say "→ downloading the dsh-computers agent"',
|
|
670
|
+
'curl -fsSL "$SERVER/api/dsh-computers/agent.mjs" -o "$AGENT" || die "could not download $SERVER/api/dsh-computers/agent.mjs"',
|
|
671
|
+
'chmod 700 "$AGENT"',
|
|
672
|
+
'',
|
|
673
|
+
'say "→ enrolling this computer with $SERVER"',
|
|
674
|
+
'node "$AGENT" enroll --server "$SERVER" --key "$KEY"',
|
|
675
|
+
'',
|
|
676
|
+
'STARTED=0',
|
|
677
|
+
'case "$(uname -s 2>/dev/null || echo unknown)" in',
|
|
678
|
+
' Darwin|Linux)',
|
|
679
|
+
' if node "$AGENT" service install >/dev/null 2>&1 && node "$AGENT" service start >/dev/null 2>&1; then',
|
|
680
|
+
' STARTED=1',
|
|
681
|
+
' fi',
|
|
682
|
+
' ;;',
|
|
683
|
+
'esac',
|
|
684
|
+
'',
|
|
685
|
+
'if [ "$STARTED" -eq 1 ]; then',
|
|
686
|
+
' say "✓ dsh-computers is connected and will reconnect automatically after a reboot."',
|
|
687
|
+
'else',
|
|
688
|
+
' say "ℹ automatic startup is not available on this machine."',
|
|
689
|
+
' say " keep this computer connected with:"',
|
|
690
|
+
' say " node \\"$AGENT\\" run"',
|
|
691
|
+
'fi',
|
|
692
|
+
'',
|
|
693
|
+
].join('\n')
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The Windows installer behind `GET /join.ps1` (PROTOCOL §4.6, best effort).
|
|
698
|
+
* @param {string} serverUrl - host origin.
|
|
699
|
+
* @param {string} key - enrollment key.
|
|
700
|
+
* @returns {string} a PowerShell script.
|
|
701
|
+
*/
|
|
702
|
+
export function joinPs1(serverUrl, key) {
|
|
703
|
+
const server = psQuote(serverUrl)
|
|
704
|
+
const token = psQuote(key)
|
|
705
|
+
return [
|
|
706
|
+
'# dsh-computers: connect this machine to a DSH Web deployment.',
|
|
707
|
+
"$ErrorActionPreference = 'Stop'",
|
|
708
|
+
`$Server = ${server}`,
|
|
709
|
+
`$Key = ${token}`,
|
|
710
|
+
"$Dir = Join-Path $HOME '.dsh-computers'",
|
|
711
|
+
"$Agent = Join-Path $Dir 'agent.mjs'",
|
|
712
|
+
'',
|
|
713
|
+
'$node = Get-Command node -ErrorAction SilentlyContinue',
|
|
714
|
+
"if ($null -eq $node) { throw 'Node.js 22 or newer is required: https://nodejs.org' }",
|
|
715
|
+
"$version = (& node -v).TrimStart('v')",
|
|
716
|
+
'if ([int]($version.Split(\'.\')[0]) -lt 22) { throw "Node.js 22 or newer is required, found v$version" }',
|
|
717
|
+
'',
|
|
718
|
+
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
|
|
719
|
+
'Write-Host "-> downloading the dsh-computers agent"',
|
|
720
|
+
"Invoke-WebRequest -UseBasicParsing -Uri \"$Server/api/dsh-computers/agent.mjs\" -OutFile $Agent",
|
|
721
|
+
'',
|
|
722
|
+
'Write-Host "-> enrolling this computer with $Server"',
|
|
723
|
+
'& node $Agent enroll --server $Server --key $Key',
|
|
724
|
+
'if ($LASTEXITCODE -ne 0) { throw "enroll failed with exit code $LASTEXITCODE" }',
|
|
725
|
+
'',
|
|
726
|
+
'Write-Host "OK dsh-computers is connected."',
|
|
727
|
+
'Write-Host " Keep this computer connected with: node `"$Agent`" run"',
|
|
728
|
+
'',
|
|
729
|
+
].join('\n')
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Serve the machine agent bundle as bytes. Read per request (never imported):
|
|
734
|
+
* the file belongs to the agent surface and may be replaced between requests.
|
|
735
|
+
* @param {import('node:http').ServerResponse} res - response to write.
|
|
736
|
+
* @param {URL} agentUrl - `new URL('./agent/dsh-computers-agent.mjs', import.meta.url)`.
|
|
737
|
+
* @returns {Promise<void>} resolves once the response is written.
|
|
738
|
+
*/
|
|
739
|
+
export async function serveAgentScript(res, agentUrl) {
|
|
740
|
+
let bytes
|
|
741
|
+
try {
|
|
742
|
+
bytes = await readFile(agentUrl)
|
|
743
|
+
} catch (error) {
|
|
744
|
+
const missing = error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
745
|
+
jsonError(res, missing ? 404 : 500, missing ? 'agent-unavailable' : 'internal',
|
|
746
|
+
missing
|
|
747
|
+
? `the machine agent bundle is missing from this deployment (${agentUrl.pathname})`
|
|
748
|
+
: 'the machine agent bundle could not be read')
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
if (res.headersSent) {
|
|
752
|
+
res.end()
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
res.writeHead(200, {
|
|
756
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
757
|
+
'cache-control': 'no-store',
|
|
758
|
+
'content-length': bytes.length,
|
|
759
|
+
})
|
|
760
|
+
res.end(bytes)
|
|
761
|
+
}
|