@crosshands/runtime 0.1.2
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/dist/broker/broker.d.ts +53 -0
- package/dist/broker/broker.d.ts.map +1 -0
- package/dist/broker/broker.js +346 -0
- package/dist/broker/broker.js.map +1 -0
- package/dist/bundle-lifecycle.d.ts +75 -0
- package/dist/bundle-lifecycle.d.ts.map +1 -0
- package/dist/bundle-lifecycle.js +247 -0
- package/dist/bundle-lifecycle.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/control-transport.d.ts +59 -0
- package/dist/ipc/control-transport.d.ts.map +1 -0
- package/dist/ipc/control-transport.js +389 -0
- package/dist/ipc/control-transport.js.map +1 -0
- package/dist/ipc/endpoint.d.ts +15 -0
- package/dist/ipc/endpoint.d.ts.map +1 -0
- package/dist/ipc/endpoint.js +16 -0
- package/dist/ipc/endpoint.js.map +1 -0
- package/dist/ipc/framing.d.ts +4 -0
- package/dist/ipc/framing.d.ts.map +1 -0
- package/dist/ipc/framing.js +32 -0
- package/dist/ipc/framing.js.map +1 -0
- package/dist/ipc/unix.d.ts +17 -0
- package/dist/ipc/unix.d.ts.map +1 -0
- package/dist/ipc/unix.js +106 -0
- package/dist/ipc/unix.js.map +1 -0
- package/dist/ipc/windows.d.ts +13 -0
- package/dist/ipc/windows.d.ts.map +1 -0
- package/dist/ipc/windows.js +10 -0
- package/dist/ipc/windows.js.map +1 -0
- package/dist/policy/policy.d.ts +23 -0
- package/dist/policy/policy.d.ts.map +1 -0
- package/dist/policy/policy.js +53 -0
- package/dist/policy/policy.js.map +1 -0
- package/dist/providers/supervisor.d.ts +17 -0
- package/dist/providers/supervisor.d.ts.map +1 -0
- package/dist/providers/supervisor.js +122 -0
- package/dist/providers/supervisor.js.map +1 -0
- package/package.json +37 -0
- package/src/broker/broker.ts +439 -0
- package/src/bundle-lifecycle.ts +385 -0
- package/src/index.ts +9 -0
- package/src/ipc/control-transport.ts +517 -0
- package/src/ipc/endpoint.ts +27 -0
- package/src/ipc/framing.ts +31 -0
- package/src/ipc/unix.ts +110 -0
- package/src/ipc/windows.ts +26 -0
- package/src/policy/policy.ts +73 -0
- package/src/providers/supervisor.ts +140 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { chmod, open, rename, unlink } from 'node:fs/promises'
|
|
3
|
+
import net, { type Socket } from 'node:net'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createComputerError,
|
|
7
|
+
negotiateVersionHandshake,
|
|
8
|
+
type ContractVersions
|
|
9
|
+
} from '@crosshands/contract'
|
|
10
|
+
|
|
11
|
+
import type { BrokerEndpoint } from './endpoint.js'
|
|
12
|
+
import { DEFAULT_MAX_CONTROL_MESSAGE_BYTES, encodeFrame } from './framing.js'
|
|
13
|
+
import { acquireUnixLease, validateUnixRuntimeDirectory, type UnixLease } from './unix.js'
|
|
14
|
+
import {
|
|
15
|
+
windowsPipeSecurity,
|
|
16
|
+
type WindowsPipeSecurityBackend,
|
|
17
|
+
type WindowsPipePeer
|
|
18
|
+
} from './windows.js'
|
|
19
|
+
|
|
20
|
+
export type LocalControlIdentity = {
|
|
21
|
+
osIdentity: string
|
|
22
|
+
graphicalSessionId: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CONTROL_RESPONSE_GRACE_MS = 100
|
|
26
|
+
|
|
27
|
+
export type LocalControlHandshake = {
|
|
28
|
+
type: 'handshake'
|
|
29
|
+
requestId: string
|
|
30
|
+
token: string
|
|
31
|
+
versions: ContractVersions
|
|
32
|
+
identity: LocalControlIdentity
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LocalPeerAuthenticator {
|
|
36
|
+
authenticate(handshake: LocalControlHandshake, socket: Socket): Promise<boolean>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LocalControlRequest = {
|
|
40
|
+
requestId: string
|
|
41
|
+
payload: unknown
|
|
42
|
+
deadlineAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LocalControlServerOptions = {
|
|
46
|
+
endpoint: BrokerEndpoint
|
|
47
|
+
runtimeDirectory?: string
|
|
48
|
+
tokenFile?: string
|
|
49
|
+
identity: LocalControlIdentity
|
|
50
|
+
handler: (request: LocalControlRequest) => Promise<unknown>
|
|
51
|
+
authenticator?: LocalPeerAuthenticator
|
|
52
|
+
windowsSecurity?: WindowsPipeSecurityBackend
|
|
53
|
+
maxFrameBytes?: number
|
|
54
|
+
maxFramesPerConnection?: number
|
|
55
|
+
now?: () => number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type ControlMessage = Record<string, unknown> & { type: string; requestId?: string }
|
|
59
|
+
|
|
60
|
+
class StreamingFrameDecoder {
|
|
61
|
+
readonly #maxBytes: number
|
|
62
|
+
#buffer = Buffer.alloc(0)
|
|
63
|
+
|
|
64
|
+
constructor(maxBytes: number) {
|
|
65
|
+
this.#maxBytes = maxBytes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
push(chunk: Buffer): unknown[] {
|
|
69
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk])
|
|
70
|
+
const frames: unknown[] = []
|
|
71
|
+
while (this.#buffer.byteLength >= 4) {
|
|
72
|
+
const length = this.#buffer.readUInt32BE(0)
|
|
73
|
+
if (length > this.#maxBytes) throw new RangeError('Frame exceeds maximum message size')
|
|
74
|
+
if (this.#buffer.byteLength < 4 + length) break
|
|
75
|
+
const payload = this.#buffer.subarray(4, 4 + length)
|
|
76
|
+
this.#buffer = this.#buffer.subarray(4 + length)
|
|
77
|
+
try {
|
|
78
|
+
frames.push(JSON.parse(payload.toString('utf8')) as unknown)
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error('Malformed JSON frame')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return frames
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameIdentity(a: LocalControlIdentity, b: LocalControlIdentity): boolean {
|
|
88
|
+
return a.osIdentity === b.osIdentity && a.graphicalSessionId === b.graphicalSessionId
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeTokenEqual(actual: string, expected: string): boolean {
|
|
92
|
+
const a = Buffer.from(actual)
|
|
93
|
+
const b = Buffer.from(expected)
|
|
94
|
+
return a.byteLength === b.byteLength && timingSafeEqual(a, b)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function writeAtomicToken(path: string, token: string): Promise<void> {
|
|
98
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
99
|
+
const handle = await open(temporary, 'wx', 0o600)
|
|
100
|
+
try {
|
|
101
|
+
await handle.writeFile(`${token}\n`, 'utf8')
|
|
102
|
+
await handle.sync()
|
|
103
|
+
} finally {
|
|
104
|
+
await handle.close()
|
|
105
|
+
}
|
|
106
|
+
await rename(temporary, path)
|
|
107
|
+
await chmod(path, 0o600)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function errorMessage(
|
|
111
|
+
requestId: string | undefined,
|
|
112
|
+
code: string,
|
|
113
|
+
message: string,
|
|
114
|
+
metadata: { retry?: boolean; remediation?: string; details?: unknown } = {}
|
|
115
|
+
): object {
|
|
116
|
+
return { type: 'error', requestId: requestId ?? '', code, message, ...metadata }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function errorMetadata(cause: unknown): {
|
|
120
|
+
retry?: boolean
|
|
121
|
+
remediation?: string
|
|
122
|
+
details?: unknown
|
|
123
|
+
} {
|
|
124
|
+
if (cause === null || typeof cause !== 'object') return {}
|
|
125
|
+
const value = cause as { retry?: unknown; remediation?: unknown; details?: unknown }
|
|
126
|
+
return {
|
|
127
|
+
...(typeof value.retry === 'boolean' ? { retry: value.retry } : {}),
|
|
128
|
+
...(typeof value.remediation === 'string' ? { remediation: value.remediation } : {}),
|
|
129
|
+
...(value.details === undefined ? {} : { details: value.details })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class LocalControlServer {
|
|
134
|
+
readonly #options: LocalControlServerOptions
|
|
135
|
+
readonly #maxFrameBytes: number
|
|
136
|
+
readonly #maxFrames: number
|
|
137
|
+
readonly #now: () => number
|
|
138
|
+
readonly #sockets = new Set<Socket>()
|
|
139
|
+
#server: net.Server | undefined
|
|
140
|
+
#lease: UnixLease | undefined
|
|
141
|
+
#token = ''
|
|
142
|
+
#requestCount = 0
|
|
143
|
+
|
|
144
|
+
constructor(options: LocalControlServerOptions) {
|
|
145
|
+
this.#options = options
|
|
146
|
+
this.#maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_CONTROL_MESSAGE_BYTES
|
|
147
|
+
this.#maxFrames = options.maxFramesPerConnection ?? 1024
|
|
148
|
+
this.#now = options.now ?? Date.now
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get requestCount(): number {
|
|
152
|
+
return this.#requestCount
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async start(): Promise<void> {
|
|
156
|
+
if (this.#server !== undefined) return
|
|
157
|
+
if (this.#options.endpoint.transport === 'unix') {
|
|
158
|
+
const runtimeDirectory = this.#options.runtimeDirectory
|
|
159
|
+
const tokenFile = this.#options.tokenFile
|
|
160
|
+
const uid = process.getuid?.()
|
|
161
|
+
if (runtimeDirectory === undefined || tokenFile === undefined || uid === undefined) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'Unix control transport requires a private runtime directory and token file'
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
await validateUnixRuntimeDirectory(runtimeDirectory, { uid, stopAt: runtimeDirectory })
|
|
167
|
+
this.#lease = await acquireUnixLease({
|
|
168
|
+
runtimeDirectory,
|
|
169
|
+
endpoint: this.#options.endpoint.address,
|
|
170
|
+
owner: `${this.#options.identity.osIdentity}:${this.#options.identity.graphicalSessionId}`,
|
|
171
|
+
isProcessAlive: (pid) => {
|
|
172
|
+
try {
|
|
173
|
+
process.kill(pid, 0)
|
|
174
|
+
return true
|
|
175
|
+
} catch {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
this.#token = randomBytes(32).toString('base64url')
|
|
181
|
+
await writeAtomicToken(tokenFile, this.#token)
|
|
182
|
+
} else {
|
|
183
|
+
const backend = windowsPipeSecurity(this.#options.windowsSecurity)
|
|
184
|
+
backend.createCurrentLogonOnlyDacl()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const server = net.createServer((socket) => this.#accept(socket))
|
|
188
|
+
this.#server = server
|
|
189
|
+
try {
|
|
190
|
+
await new Promise<void>((resolve, reject) => {
|
|
191
|
+
server.once('error', reject)
|
|
192
|
+
server.listen(this.#options.endpoint.address, () => {
|
|
193
|
+
server.off('error', reject)
|
|
194
|
+
resolve()
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
if (this.#options.endpoint.transport === 'unix') {
|
|
198
|
+
await chmod(this.#options.endpoint.address, 0o600)
|
|
199
|
+
}
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
await this.close()
|
|
202
|
+
throw cause
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#accept(socket: Socket): void {
|
|
207
|
+
this.#sockets.add(socket)
|
|
208
|
+
const decoder = new StreamingFrameDecoder(this.#maxFrameBytes)
|
|
209
|
+
let authenticated = false
|
|
210
|
+
let frameCount = 0
|
|
211
|
+
let chain = Promise.resolve()
|
|
212
|
+
socket.on('close', () => this.#sockets.delete(socket))
|
|
213
|
+
socket.on('error', () => undefined)
|
|
214
|
+
socket.on('data', (chunk: Buffer) => {
|
|
215
|
+
chain = chain
|
|
216
|
+
.then(async () => {
|
|
217
|
+
for (const raw of decoder.push(chunk)) {
|
|
218
|
+
frameCount += 1
|
|
219
|
+
if (frameCount > this.#maxFrames) throw new Error('Connection frame limit exceeded')
|
|
220
|
+
if (raw === null || typeof raw !== 'object' || !('type' in raw)) {
|
|
221
|
+
throw new Error('Malformed control message')
|
|
222
|
+
}
|
|
223
|
+
const message = raw as ControlMessage
|
|
224
|
+
if (!authenticated) {
|
|
225
|
+
if (message.type !== 'handshake') {
|
|
226
|
+
this.#sendAndClose(
|
|
227
|
+
socket,
|
|
228
|
+
errorMessage(message.requestId, 'handshake_required', 'Handshake is required')
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
// oxlint-disable-next-line no-await-in-loop -- frames on one connection are ordered.
|
|
233
|
+
authenticated = await this.#authenticate(message, socket)
|
|
234
|
+
if (!authenticated) return
|
|
235
|
+
this.#send(socket, { type: 'handshake-ok', requestId: message.requestId })
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (message.type !== 'request') {
|
|
239
|
+
this.#sendAndClose(
|
|
240
|
+
socket,
|
|
241
|
+
errorMessage(message.requestId, 'invalid_message', 'Expected a request message')
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
// oxlint-disable-next-line no-await-in-loop -- responses preserve request frame order.
|
|
246
|
+
await this.#handleRequest(socket, message)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
.catch(() => {
|
|
250
|
+
socket.destroy()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async #authenticate(message: ControlMessage, socket: Socket): Promise<boolean> {
|
|
256
|
+
const handshake = message as unknown as LocalControlHandshake
|
|
257
|
+
if (
|
|
258
|
+
typeof handshake.requestId !== 'string' ||
|
|
259
|
+
typeof handshake.token !== 'string' ||
|
|
260
|
+
handshake.identity === null ||
|
|
261
|
+
typeof handshake.identity !== 'object' ||
|
|
262
|
+
!sameIdentity(handshake.identity, this.#options.identity)
|
|
263
|
+
) {
|
|
264
|
+
this.#sendAndClose(
|
|
265
|
+
socket,
|
|
266
|
+
errorMessage(handshake.requestId, 'peer_rejected', 'Peer identity was rejected')
|
|
267
|
+
)
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
const versions = negotiateVersionHandshake(handshake.versions)
|
|
271
|
+
if (!versions.ok) {
|
|
272
|
+
this.#sendAndClose(
|
|
273
|
+
socket,
|
|
274
|
+
errorMessage(
|
|
275
|
+
handshake.requestId,
|
|
276
|
+
versions.error.code,
|
|
277
|
+
versions.error.message,
|
|
278
|
+
versions.error
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
let accepted: boolean
|
|
284
|
+
if (this.#options.authenticator !== undefined) {
|
|
285
|
+
accepted = await this.#options.authenticator.authenticate(handshake, socket)
|
|
286
|
+
} else if (this.#options.endpoint.transport === 'unix') {
|
|
287
|
+
accepted = safeTokenEqual(handshake.token, this.#token)
|
|
288
|
+
} else {
|
|
289
|
+
const peer: WindowsPipePeer = this.#options.windowsSecurity!.verifyPeer()
|
|
290
|
+
accepted =
|
|
291
|
+
!peer.remote &&
|
|
292
|
+
peer.sid === this.#options.identity.osIdentity &&
|
|
293
|
+
peer.logonSessionId === this.#options.identity.graphicalSessionId
|
|
294
|
+
}
|
|
295
|
+
if (!accepted) {
|
|
296
|
+
this.#sendAndClose(
|
|
297
|
+
socket,
|
|
298
|
+
errorMessage(handshake.requestId, 'peer_rejected', 'Peer authentication failed')
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
return accepted
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async #handleRequest(socket: Socket, message: ControlMessage): Promise<void> {
|
|
305
|
+
if (
|
|
306
|
+
typeof message.requestId !== 'string' ||
|
|
307
|
+
typeof message.deadlineAt !== 'number' ||
|
|
308
|
+
!Number.isFinite(message.deadlineAt)
|
|
309
|
+
) {
|
|
310
|
+
this.#sendAndClose(
|
|
311
|
+
socket,
|
|
312
|
+
errorMessage(message.requestId, 'invalid_request', 'Request ID and deadline are required')
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (message.deadlineAt <= this.#now()) {
|
|
317
|
+
this.#send(socket, errorMessage(message.requestId, 'timeout', 'Request deadline elapsed'))
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
this.#requestCount += 1
|
|
321
|
+
const request: LocalControlRequest = {
|
|
322
|
+
requestId: message.requestId,
|
|
323
|
+
payload: message.payload,
|
|
324
|
+
deadlineAt: message.deadlineAt
|
|
325
|
+
}
|
|
326
|
+
let timer: NodeJS.Timeout | undefined
|
|
327
|
+
try {
|
|
328
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
329
|
+
timer = setTimeout(
|
|
330
|
+
() => reject(createComputerError('timeout', 'Control request deadline elapsed')),
|
|
331
|
+
(message.deadlineAt as number) - this.#now() + CONTROL_RESPONSE_GRACE_MS
|
|
332
|
+
)
|
|
333
|
+
timer.unref()
|
|
334
|
+
})
|
|
335
|
+
const result = await Promise.race([this.#options.handler(request), timeout])
|
|
336
|
+
this.#send(socket, { type: 'response', requestId: message.requestId, result })
|
|
337
|
+
} catch (cause) {
|
|
338
|
+
const code =
|
|
339
|
+
typeof cause === 'object' && cause !== null && 'code' in cause
|
|
340
|
+
? String(cause.code)
|
|
341
|
+
: 'request_failed'
|
|
342
|
+
this.#send(
|
|
343
|
+
socket,
|
|
344
|
+
errorMessage(
|
|
345
|
+
message.requestId,
|
|
346
|
+
code,
|
|
347
|
+
cause instanceof Error ? cause.message : 'Request failed',
|
|
348
|
+
errorMetadata(cause)
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
} finally {
|
|
352
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#send(socket: Socket, message: unknown): void {
|
|
357
|
+
socket.write(encodeFrame(message, this.#maxFrameBytes))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#sendAndClose(socket: Socket, message: unknown): void {
|
|
361
|
+
socket.end(encodeFrame(message, this.#maxFrameBytes))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async close(): Promise<void> {
|
|
365
|
+
const server = this.#server
|
|
366
|
+
this.#server = undefined
|
|
367
|
+
for (const socket of this.#sockets) socket.destroy()
|
|
368
|
+
this.#sockets.clear()
|
|
369
|
+
if (server !== undefined) {
|
|
370
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
371
|
+
}
|
|
372
|
+
if (this.#options.endpoint.transport === 'unix') {
|
|
373
|
+
await unlink(this.#options.endpoint.address).catch(() => undefined)
|
|
374
|
+
if (this.#options.tokenFile !== undefined) {
|
|
375
|
+
await unlink(this.#options.tokenFile).catch(() => undefined)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
await this.#lease?.release()
|
|
379
|
+
this.#lease = undefined
|
|
380
|
+
this.#token = ''
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export type LocalControlClientOptions = {
|
|
385
|
+
endpoint: BrokerEndpoint
|
|
386
|
+
token: string
|
|
387
|
+
versions: ContractVersions
|
|
388
|
+
identity: LocalControlIdentity
|
|
389
|
+
maxFrameBytes?: number
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export class LocalControlClient {
|
|
393
|
+
readonly #socket: Socket
|
|
394
|
+
readonly #maxFrameBytes: number
|
|
395
|
+
readonly #pending = new Map<
|
|
396
|
+
string,
|
|
397
|
+
{
|
|
398
|
+
resolve: (value: unknown) => void
|
|
399
|
+
reject: (reason: unknown) => void
|
|
400
|
+
timer: NodeJS.Timeout
|
|
401
|
+
}
|
|
402
|
+
>()
|
|
403
|
+
#sequence = 0
|
|
404
|
+
|
|
405
|
+
private constructor(socket: Socket, maxFrameBytes: number) {
|
|
406
|
+
this.#socket = socket
|
|
407
|
+
this.#maxFrameBytes = maxFrameBytes
|
|
408
|
+
const decoder = new StreamingFrameDecoder(maxFrameBytes)
|
|
409
|
+
socket.on('data', (chunk: Buffer) => {
|
|
410
|
+
try {
|
|
411
|
+
for (const raw of decoder.push(chunk)) this.#receive(raw)
|
|
412
|
+
} catch (cause) {
|
|
413
|
+
this.#failAll(cause)
|
|
414
|
+
socket.destroy()
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
socket.on('error', (cause) => this.#failAll(cause))
|
|
418
|
+
socket.on('close', () => this.#failAll(new Error('Control connection closed')))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
static async connect(options: LocalControlClientOptions): Promise<LocalControlClient> {
|
|
422
|
+
const socket = net.createConnection(options.endpoint.address)
|
|
423
|
+
await new Promise<void>((resolve, reject) => {
|
|
424
|
+
socket.once('connect', resolve)
|
|
425
|
+
socket.once('error', reject)
|
|
426
|
+
})
|
|
427
|
+
const client = new LocalControlClient(
|
|
428
|
+
socket,
|
|
429
|
+
options.maxFrameBytes ?? DEFAULT_MAX_CONTROL_MESSAGE_BYTES
|
|
430
|
+
)
|
|
431
|
+
const requestId = `handshake-${randomUUID()}`
|
|
432
|
+
const response = client.#roundTrip(
|
|
433
|
+
requestId,
|
|
434
|
+
{
|
|
435
|
+
type: 'handshake',
|
|
436
|
+
requestId,
|
|
437
|
+
token: options.token,
|
|
438
|
+
versions: options.versions,
|
|
439
|
+
identity: options.identity
|
|
440
|
+
},
|
|
441
|
+
30_000
|
|
442
|
+
)
|
|
443
|
+
await response
|
|
444
|
+
return client
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
request(payload: unknown, options: { deadlineMs: number }): Promise<unknown> {
|
|
448
|
+
const requestId = `request-${++this.#sequence}`
|
|
449
|
+
return this.#roundTrip(
|
|
450
|
+
requestId,
|
|
451
|
+
{
|
|
452
|
+
type: 'request',
|
|
453
|
+
requestId,
|
|
454
|
+
deadlineAt: Date.now() + options.deadlineMs,
|
|
455
|
+
payload
|
|
456
|
+
},
|
|
457
|
+
Math.max(0, options.deadlineMs) + CONTROL_RESPONSE_GRACE_MS
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#roundTrip(requestId: string, message: unknown, timeoutMs: number): Promise<unknown> {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const timer = setTimeout(() => {
|
|
464
|
+
this.#pending.delete(requestId)
|
|
465
|
+
reject(createComputerError('timeout', 'Control request timed out'))
|
|
466
|
+
}, timeoutMs)
|
|
467
|
+
timer.unref()
|
|
468
|
+
this.#pending.set(requestId, { resolve, reject, timer })
|
|
469
|
+
this.#socket.write(encodeFrame(message, this.#maxFrameBytes), (cause) => {
|
|
470
|
+
if (cause !== null && cause !== undefined) {
|
|
471
|
+
this.#pending.delete(requestId)
|
|
472
|
+
clearTimeout(timer)
|
|
473
|
+
reject(cause)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#receive(raw: unknown): void {
|
|
480
|
+
if (raw === null || typeof raw !== 'object') return
|
|
481
|
+
const message = raw as Record<string, unknown>
|
|
482
|
+
if (typeof message.requestId !== 'string') return
|
|
483
|
+
const pending = this.#pending.get(message.requestId)
|
|
484
|
+
if (pending === undefined) return
|
|
485
|
+
this.#pending.delete(message.requestId)
|
|
486
|
+
clearTimeout(pending.timer)
|
|
487
|
+
if (message.type === 'error') {
|
|
488
|
+
pending.reject(
|
|
489
|
+
Object.assign(new Error(String(message.message)), {
|
|
490
|
+
code: message.code,
|
|
491
|
+
retry: message.retry,
|
|
492
|
+
remediation: message.remediation,
|
|
493
|
+
...(message.details === undefined ? {} : { details: message.details })
|
|
494
|
+
})
|
|
495
|
+
)
|
|
496
|
+
} else if (message.type === 'response') {
|
|
497
|
+
pending.resolve(message.result)
|
|
498
|
+
} else if (message.type === 'handshake-ok') {
|
|
499
|
+
pending.resolve(undefined)
|
|
500
|
+
} else {
|
|
501
|
+
pending.reject(new Error('Unexpected control response'))
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#failAll(cause: unknown): void {
|
|
506
|
+
for (const pending of this.#pending.values()) {
|
|
507
|
+
clearTimeout(pending.timer)
|
|
508
|
+
pending.reject(cause)
|
|
509
|
+
}
|
|
510
|
+
this.#pending.clear()
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async close(): Promise<void> {
|
|
514
|
+
if (this.#socket.destroyed) return
|
|
515
|
+
await new Promise<void>((resolve) => this.#socket.end(resolve))
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type BrokerEndpoint =
|
|
5
|
+
| { transport: 'unix'; address: string }
|
|
6
|
+
| { transport: 'named-pipe'; address: string }
|
|
7
|
+
|
|
8
|
+
export type BrokerEndpointOptions = {
|
|
9
|
+
platform: NodeJS.Platform
|
|
10
|
+
osIdentity: string
|
|
11
|
+
graphicalSessionId: string
|
|
12
|
+
runtimeDirectory?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function brokerEndpoint(options: BrokerEndpointOptions): BrokerEndpoint {
|
|
16
|
+
const key = createHash('sha256')
|
|
17
|
+
.update(`${options.osIdentity}\0${options.graphicalSessionId}`)
|
|
18
|
+
.digest('hex')
|
|
19
|
+
.slice(0, 24)
|
|
20
|
+
if (options.platform === 'win32') {
|
|
21
|
+
return { transport: 'named-pipe', address: `\\\\.\\pipe\\crosshands-${key}` }
|
|
22
|
+
}
|
|
23
|
+
if (options.runtimeDirectory === undefined) {
|
|
24
|
+
throw new Error('A private runtime directory is required for Unix IPC')
|
|
25
|
+
}
|
|
26
|
+
return { transport: 'unix', address: join(options.runtimeDirectory, `${key}.sock`) }
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const DEFAULT_MAX_CONTROL_MESSAGE_BYTES = 1_048_576
|
|
2
|
+
|
|
3
|
+
export function encodeFrame(value: unknown, maxBytes = DEFAULT_MAX_CONTROL_MESSAGE_BYTES): Buffer {
|
|
4
|
+
const payload = Buffer.from(JSON.stringify(value), 'utf8')
|
|
5
|
+
if (payload.byteLength > maxBytes) throw new RangeError('Frame exceeds maximum message size')
|
|
6
|
+
const header = Buffer.allocUnsafe(4)
|
|
7
|
+
header.writeUInt32BE(payload.byteLength)
|
|
8
|
+
return Buffer.concat([header, payload])
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function decodeFrames(
|
|
12
|
+
buffer: Buffer,
|
|
13
|
+
maxBytes = DEFAULT_MAX_CONTROL_MESSAGE_BYTES
|
|
14
|
+
): unknown[] {
|
|
15
|
+
const frames: unknown[] = []
|
|
16
|
+
let offset = 0
|
|
17
|
+
while (offset < buffer.byteLength) {
|
|
18
|
+
if (buffer.byteLength - offset < 4) throw new Error('Malformed incomplete frame header')
|
|
19
|
+
const length = buffer.readUInt32BE(offset)
|
|
20
|
+
if (length > maxBytes) throw new RangeError('Frame exceeds maximum message size')
|
|
21
|
+
const end = offset + 4 + length
|
|
22
|
+
if (end > buffer.byteLength) throw new Error('Malformed incomplete frame payload')
|
|
23
|
+
try {
|
|
24
|
+
frames.push(JSON.parse(buffer.subarray(offset + 4, end).toString('utf8')) as unknown)
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error('Malformed JSON frame')
|
|
27
|
+
}
|
|
28
|
+
offset = end
|
|
29
|
+
}
|
|
30
|
+
return frames
|
|
31
|
+
}
|
package/src/ipc/unix.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { open, lstat, readFile, unlink } from 'node:fs/promises'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type UnixRuntimeValidationOptions = {
|
|
5
|
+
uid: number
|
|
6
|
+
stopAt?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function validateUnixRuntimeDirectory(
|
|
10
|
+
runtimeDirectory: string,
|
|
11
|
+
options: UnixRuntimeValidationOptions
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const stopAt = resolve(options.stopAt ?? runtimeDirectory)
|
|
14
|
+
let current = resolve(runtimeDirectory)
|
|
15
|
+
while (true) {
|
|
16
|
+
// oxlint-disable-next-line no-await-in-loop -- path components must be checked in order.
|
|
17
|
+
const stat = await lstat(current)
|
|
18
|
+
if (stat.isSymbolicLink()) throw new Error('Unix runtime path contains a symbolic link')
|
|
19
|
+
if (!stat.isDirectory()) throw new Error('Unix runtime path is not a directory')
|
|
20
|
+
if (stat.uid !== options.uid) throw new Error('Unix runtime directory has unsafe ownership')
|
|
21
|
+
if ((stat.mode & 0o077) !== 0) throw new Error('Unix runtime directory has unsafe mode')
|
|
22
|
+
if (current === stopAt) break
|
|
23
|
+
const parent = dirname(current)
|
|
24
|
+
if (parent === current || !current.startsWith(`${stopAt}/`)) {
|
|
25
|
+
throw new Error('Unix runtime directory escaped its validated root')
|
|
26
|
+
}
|
|
27
|
+
current = parent
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type UnixLeaseOptions = {
|
|
32
|
+
runtimeDirectory: string
|
|
33
|
+
endpoint: string
|
|
34
|
+
owner: string
|
|
35
|
+
isProcessAlive: (pid: number) => boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type UnixLease = { release(): Promise<void> }
|
|
39
|
+
|
|
40
|
+
async function rejectPrecreatedEndpoint(endpoint: string): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
await lstat(endpoint)
|
|
43
|
+
throw new Error('Broker endpoint already exists or was precreated')
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') return
|
|
46
|
+
throw cause
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function removeVerifiedStaleEndpoint(endpoint: string, uid: number): Promise<void> {
|
|
51
|
+
let info
|
|
52
|
+
try {
|
|
53
|
+
info = await lstat(endpoint)
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') return
|
|
56
|
+
throw cause
|
|
57
|
+
}
|
|
58
|
+
if (!info.isSocket() || info.uid !== uid || (info.mode & 0o077) !== 0) {
|
|
59
|
+
throw new Error('Stale broker endpoint is unsafe to remove')
|
|
60
|
+
}
|
|
61
|
+
await unlink(endpoint)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function acquireUnixLease(options: UnixLeaseOptions): Promise<UnixLease> {
|
|
65
|
+
const uid = process.getuid?.()
|
|
66
|
+
if (uid === undefined) throw new Error('Unix lease is unsupported on this platform')
|
|
67
|
+
await validateUnixRuntimeDirectory(options.runtimeDirectory, {
|
|
68
|
+
uid,
|
|
69
|
+
stopAt: options.runtimeDirectory
|
|
70
|
+
})
|
|
71
|
+
const leasePath = `${options.endpoint}.lease`
|
|
72
|
+
|
|
73
|
+
let handle
|
|
74
|
+
try {
|
|
75
|
+
handle = await open(leasePath, 'wx', 0o600)
|
|
76
|
+
try {
|
|
77
|
+
await rejectPrecreatedEndpoint(options.endpoint)
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
await handle.close()
|
|
80
|
+
await unlink(leasePath)
|
|
81
|
+
throw cause
|
|
82
|
+
}
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
if (!(cause instanceof Error && 'code' in cause && cause.code === 'EEXIST')) throw cause
|
|
85
|
+
let stale = false
|
|
86
|
+
try {
|
|
87
|
+
const existing = JSON.parse(await readFile(leasePath, 'utf8')) as { pid?: unknown }
|
|
88
|
+
stale = typeof existing.pid === 'number' && !options.isProcessAlive(existing.pid)
|
|
89
|
+
} catch (parseCause) {
|
|
90
|
+
throw new Error('Existing broker lease is unverifiable', { cause: parseCause })
|
|
91
|
+
}
|
|
92
|
+
if (!stale) throw new Error('A compatible broker lease is already active', { cause })
|
|
93
|
+
await removeVerifiedStaleEndpoint(options.endpoint, uid)
|
|
94
|
+
await unlink(leasePath)
|
|
95
|
+
handle = await open(leasePath, 'wx', 0o600)
|
|
96
|
+
}
|
|
97
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, owner: options.owner }), 'utf8')
|
|
98
|
+
await handle.sync()
|
|
99
|
+
await handle.close()
|
|
100
|
+
let released = false
|
|
101
|
+
return {
|
|
102
|
+
async release() {
|
|
103
|
+
if (released) return
|
|
104
|
+
released = true
|
|
105
|
+
await unlink(leasePath).catch((cause: unknown) => {
|
|
106
|
+
if (!(cause instanceof Error && 'code' in cause && cause.code === 'ENOENT')) throw cause
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|