@crosshands/platform-windows 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/NOTICE.md +7 -0
- package/assets/crosshands-pipe-relay.exe +0 -0
- package/assets/payload.json +13 -0
- package/assets/runtime.ps1 +1426 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +51 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +547 -0
- package/dist/provider.js.map +1 -0
- package/dist/security.d.ts +25 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +27 -0
- package/dist/security.js.map +1 -0
- package/dist/windows-control-security.d.ts +95 -0
- package/dist/windows-control-security.d.ts.map +1 -0
- package/dist/windows-control-security.js +432 -0
- package/dist/windows-control-security.js.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +76 -0
- package/src/provider.ts +646 -0
- package/src/security.ts +67 -0
- package/src/windows-control-security.ts +562 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { lstat, readFile } from 'node:fs/promises'
|
|
4
|
+
import { isAbsolute } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { negotiateVersionHandshake, type ContractVersions } from '@crosshands/contract'
|
|
7
|
+
import { DEFAULT_MAX_CONTROL_MESSAGE_BYTES, encodeFrame } from '@crosshands/runtime'
|
|
8
|
+
|
|
9
|
+
const RELAY_MAGIC = 0x53504858
|
|
10
|
+
const RELAY_VERSION = 1
|
|
11
|
+
const HEADER_BYTES = 20
|
|
12
|
+
const MAX_RELAY_PAYLOAD_BYTES = 4 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
export const WINDOWS_CONTROL_RELAY_RECORD = {
|
|
15
|
+
open: 1,
|
|
16
|
+
data: 2,
|
|
17
|
+
close: 3,
|
|
18
|
+
error: 4,
|
|
19
|
+
ready: 5,
|
|
20
|
+
shutdown: 6
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type WindowsControlPeer = {
|
|
24
|
+
pid: number
|
|
25
|
+
userSid: string
|
|
26
|
+
logonSid: string
|
|
27
|
+
logonSessionId: string
|
|
28
|
+
integrityRid: number
|
|
29
|
+
windowsSessionId: number
|
|
30
|
+
processStartedAt: string
|
|
31
|
+
executablePath: string
|
|
32
|
+
remote: false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type WindowsControlIdentity = {
|
|
36
|
+
osIdentity: string
|
|
37
|
+
graphicalSessionId: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function windowsControlIdentity(peer: WindowsControlPeer): WindowsControlIdentity {
|
|
41
|
+
return {
|
|
42
|
+
osIdentity: `sid:${peer.userSid}`,
|
|
43
|
+
graphicalSessionId: `logon:${peer.logonSessionId}:session:${peer.windowsSessionId}`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type WindowsControlRelayRecord = {
|
|
48
|
+
type: number
|
|
49
|
+
connectionId: bigint
|
|
50
|
+
payload: Buffer
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class WindowsControlRelayDecoder {
|
|
54
|
+
#buffer = Buffer.alloc(0)
|
|
55
|
+
|
|
56
|
+
push(chunk: Buffer): WindowsControlRelayRecord[] {
|
|
57
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk])
|
|
58
|
+
const records: WindowsControlRelayRecord[] = []
|
|
59
|
+
while (this.#buffer.byteLength >= HEADER_BYTES) {
|
|
60
|
+
if (this.#buffer.readUInt32LE(0) !== RELAY_MAGIC) throw new Error('Invalid relay magic')
|
|
61
|
+
if (this.#buffer.readUInt16LE(4) !== RELAY_VERSION) throw new Error('Invalid relay version')
|
|
62
|
+
const length = this.#buffer.readUInt32LE(16)
|
|
63
|
+
if (length > MAX_RELAY_PAYLOAD_BYTES) throw new Error('Relay payload exceeds limit')
|
|
64
|
+
if (this.#buffer.byteLength < HEADER_BYTES + length) break
|
|
65
|
+
records.push({
|
|
66
|
+
type: this.#buffer.readUInt16LE(6),
|
|
67
|
+
connectionId: this.#buffer.readBigUInt64LE(8),
|
|
68
|
+
payload: this.#buffer.subarray(HEADER_BYTES, HEADER_BYTES + length)
|
|
69
|
+
})
|
|
70
|
+
this.#buffer = this.#buffer.subarray(HEADER_BYTES + length)
|
|
71
|
+
}
|
|
72
|
+
return records
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function encodeWindowsControlRelayRecord(
|
|
77
|
+
type: number,
|
|
78
|
+
connectionId: bigint,
|
|
79
|
+
payload: Buffer = Buffer.alloc(0)
|
|
80
|
+
): Buffer {
|
|
81
|
+
if (payload.byteLength > MAX_RELAY_PAYLOAD_BYTES) throw new Error('Relay payload exceeds limit')
|
|
82
|
+
const frame = Buffer.allocUnsafe(HEADER_BYTES + payload.byteLength)
|
|
83
|
+
frame.writeUInt32LE(RELAY_MAGIC, 0)
|
|
84
|
+
frame.writeUInt16LE(RELAY_VERSION, 4)
|
|
85
|
+
frame.writeUInt16LE(type, 6)
|
|
86
|
+
frame.writeBigUInt64LE(connectionId, 8)
|
|
87
|
+
frame.writeUInt32LE(payload.byteLength, 16)
|
|
88
|
+
payload.copy(frame, HEADER_BYTES)
|
|
89
|
+
return frame
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type WindowsControlRelayOptions = {
|
|
93
|
+
helperPath: string
|
|
94
|
+
helperSha256: string
|
|
95
|
+
pipeName: string
|
|
96
|
+
onConnection: (connection: WindowsControlConnection) => void
|
|
97
|
+
spawnProcess?: typeof spawn
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type WindowsRelayControlRequest = {
|
|
101
|
+
requestId: string
|
|
102
|
+
payload: unknown
|
|
103
|
+
deadlineAt: number
|
|
104
|
+
peer: WindowsControlPeer
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type WindowsRelayControlServerOptions = {
|
|
108
|
+
relay: Omit<WindowsControlRelayOptions, 'onConnection'>
|
|
109
|
+
identity: WindowsControlIdentity
|
|
110
|
+
handler: (request: WindowsRelayControlRequest) => Promise<unknown>
|
|
111
|
+
createRelay?: (options: WindowsControlRelayOptions) => WindowsControlRelay
|
|
112
|
+
maxFrameBytes?: number
|
|
113
|
+
maxFramesPerConnection?: number
|
|
114
|
+
now?: () => number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface WindowsControlRelay {
|
|
118
|
+
start(): Promise<void>
|
|
119
|
+
close(): Promise<void>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type WindowsControlConnection = {
|
|
123
|
+
id: bigint
|
|
124
|
+
peer: WindowsControlPeer
|
|
125
|
+
write(payload: Buffer): void
|
|
126
|
+
close(): void
|
|
127
|
+
onData(listener: (payload: Buffer) => void): void
|
|
128
|
+
onClose(listener: () => void): void
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsePeer(payload: Buffer): WindowsControlPeer {
|
|
132
|
+
const raw = JSON.parse(payload.toString('utf8')) as Partial<WindowsControlPeer>
|
|
133
|
+
if (
|
|
134
|
+
!Number.isSafeInteger(raw.pid) ||
|
|
135
|
+
(raw.pid ?? 0) <= 0 ||
|
|
136
|
+
typeof raw.userSid !== 'string' ||
|
|
137
|
+
typeof raw.logonSid !== 'string' ||
|
|
138
|
+
typeof raw.logonSessionId !== 'string' ||
|
|
139
|
+
!Number.isSafeInteger(raw.integrityRid) ||
|
|
140
|
+
!Number.isSafeInteger(raw.windowsSessionId) ||
|
|
141
|
+
typeof raw.processStartedAt !== 'string' ||
|
|
142
|
+
typeof raw.executablePath !== 'string' ||
|
|
143
|
+
raw.remote !== false
|
|
144
|
+
) {
|
|
145
|
+
throw new Error('Native relay returned incomplete peer identity')
|
|
146
|
+
}
|
|
147
|
+
return raw as WindowsControlPeer
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function verifyWindowsControlRelay(
|
|
151
|
+
options: WindowsControlRelayOptions
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
if (!isAbsolute(options.helperPath)) throw new Error('Windows relay path must be absolute')
|
|
154
|
+
if (!/^\\\\\.\\pipe\\crosshands-[a-f0-9]{24}$/.test(options.pipeName)) {
|
|
155
|
+
throw new Error('Windows relay pipe name is not a CrossHands session endpoint')
|
|
156
|
+
}
|
|
157
|
+
if (!/^[a-f0-9]{64}$/.test(options.helperSha256)) {
|
|
158
|
+
throw new Error('Windows relay SHA-256 is malformed')
|
|
159
|
+
}
|
|
160
|
+
const info = await lstat(options.helperPath)
|
|
161
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
162
|
+
throw new Error('Windows relay payload is not a regular package file')
|
|
163
|
+
}
|
|
164
|
+
const bytes = await readFile(options.helperPath)
|
|
165
|
+
const actual = createHash('sha256').update(bytes).digest('hex')
|
|
166
|
+
if (actual !== options.helperSha256) throw new Error('Windows relay payload hash mismatch')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type ConnectionState = {
|
|
170
|
+
dataListeners: Array<(payload: Buffer) => void>
|
|
171
|
+
closeListeners: Array<() => void>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Launches the native process that owns the named pipe. Node never creates or
|
|
176
|
+
* accepts a Windows pipe directly: unverified bytes cannot reach the broker.
|
|
177
|
+
*/
|
|
178
|
+
export class NativeWindowsControlRelay implements WindowsControlRelay {
|
|
179
|
+
readonly #options: WindowsControlRelayOptions
|
|
180
|
+
readonly #connections = new Map<bigint, ConnectionState>()
|
|
181
|
+
#child: ChildProcessWithoutNullStreams | undefined
|
|
182
|
+
#ready: Promise<void> | undefined
|
|
183
|
+
|
|
184
|
+
constructor(options: WindowsControlRelayOptions) {
|
|
185
|
+
this.#options = options
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async start(): Promise<void> {
|
|
189
|
+
if (this.#child !== undefined) return this.#ready
|
|
190
|
+
if (process.platform !== 'win32') throw new Error('Windows relay can only run on Windows')
|
|
191
|
+
await verifyWindowsControlRelay(this.#options)
|
|
192
|
+
const child = (this.#options.spawnProcess ?? spawn)(
|
|
193
|
+
this.#options.helperPath,
|
|
194
|
+
['--pipe', this.#options.pipeName, '--broker-pid', String(process.pid)],
|
|
195
|
+
{
|
|
196
|
+
cwd: undefined,
|
|
197
|
+
windowsHide: true,
|
|
198
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
199
|
+
env: {
|
|
200
|
+
SystemRoot: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows',
|
|
201
|
+
WINDIR: process.env.WINDIR ?? process.env.SystemRoot ?? 'C:\\Windows'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
this.#child = child
|
|
206
|
+
const decoder = new WindowsControlRelayDecoder()
|
|
207
|
+
this.#ready = new Promise<void>((resolve, reject) => {
|
|
208
|
+
let settled = false
|
|
209
|
+
let stderrBytes = 0
|
|
210
|
+
const fail = (error: Error): void => {
|
|
211
|
+
if (!settled) {
|
|
212
|
+
settled = true
|
|
213
|
+
reject(error)
|
|
214
|
+
}
|
|
215
|
+
this.#failAll()
|
|
216
|
+
}
|
|
217
|
+
child.once('error', (cause) => fail(cause))
|
|
218
|
+
child.once('exit', (code) =>
|
|
219
|
+
fail(new Error(`Windows relay exited with code ${String(code)}`))
|
|
220
|
+
)
|
|
221
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
222
|
+
stderrBytes += chunk.byteLength
|
|
223
|
+
if (stderrBytes > 64 * 1024) child.kill()
|
|
224
|
+
})
|
|
225
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
226
|
+
try {
|
|
227
|
+
for (const record of decoder.push(chunk)) {
|
|
228
|
+
if (record.type === WINDOWS_CONTROL_RELAY_RECORD.ready) {
|
|
229
|
+
if (!settled) {
|
|
230
|
+
settled = true
|
|
231
|
+
resolve()
|
|
232
|
+
}
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
this.#receive(record)
|
|
236
|
+
}
|
|
237
|
+
} catch (cause) {
|
|
238
|
+
child.kill()
|
|
239
|
+
fail(cause instanceof Error ? cause : new Error('Windows relay protocol failed'))
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
return this.#ready
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#receive(record: WindowsControlRelayRecord): void {
|
|
247
|
+
if (record.type === WINDOWS_CONTROL_RELAY_RECORD.open) {
|
|
248
|
+
if (this.#connections.has(record.connectionId)) throw new Error('Duplicate relay connection')
|
|
249
|
+
const state: ConnectionState = { dataListeners: [], closeListeners: [] }
|
|
250
|
+
this.#connections.set(record.connectionId, state)
|
|
251
|
+
const connection: WindowsControlConnection = {
|
|
252
|
+
id: record.connectionId,
|
|
253
|
+
peer: parsePeer(record.payload),
|
|
254
|
+
write: (payload) =>
|
|
255
|
+
this.#write(WINDOWS_CONTROL_RELAY_RECORD.data, record.connectionId, payload),
|
|
256
|
+
close: () => this.#write(WINDOWS_CONTROL_RELAY_RECORD.close, record.connectionId),
|
|
257
|
+
onData: (listener) => state.dataListeners.push(listener),
|
|
258
|
+
onClose: (listener) => state.closeListeners.push(listener)
|
|
259
|
+
}
|
|
260
|
+
this.#options.onConnection(connection)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const state = this.#connections.get(record.connectionId)
|
|
264
|
+
if (state === undefined) throw new Error('Relay referenced an unknown connection')
|
|
265
|
+
if (record.type === WINDOWS_CONTROL_RELAY_RECORD.data) {
|
|
266
|
+
for (const listener of state.dataListeners) listener(record.payload)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
record.type === WINDOWS_CONTROL_RELAY_RECORD.close ||
|
|
271
|
+
record.type === WINDOWS_CONTROL_RELAY_RECORD.error
|
|
272
|
+
) {
|
|
273
|
+
this.#connections.delete(record.connectionId)
|
|
274
|
+
for (const listener of state.closeListeners) listener()
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
throw new Error('Unknown relay record type')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#write(type: number, connectionId: bigint, payload?: Buffer): void {
|
|
281
|
+
const stdin = this.#child?.stdin
|
|
282
|
+
if (stdin === undefined || !stdin.writable) throw new Error('Windows relay is not writable')
|
|
283
|
+
if (!stdin.write(encodeWindowsControlRelayRecord(type, connectionId, payload))) {
|
|
284
|
+
this.#child?.kill()
|
|
285
|
+
throw new Error('Windows relay backpressure limit reached')
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#failAll(): void {
|
|
290
|
+
for (const state of this.#connections.values()) {
|
|
291
|
+
for (const listener of state.closeListeners) listener()
|
|
292
|
+
}
|
|
293
|
+
this.#connections.clear()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async close(): Promise<void> {
|
|
297
|
+
const child = this.#child
|
|
298
|
+
this.#child = undefined
|
|
299
|
+
this.#ready = undefined
|
|
300
|
+
if (child === undefined) return
|
|
301
|
+
if (child.stdin.writable) {
|
|
302
|
+
child.stdin.end(encodeWindowsControlRelayRecord(WINDOWS_CONTROL_RELAY_RECORD.shutdown, 0n))
|
|
303
|
+
}
|
|
304
|
+
await new Promise<void>((resolve) => {
|
|
305
|
+
const timer = setTimeout(() => {
|
|
306
|
+
child.kill()
|
|
307
|
+
resolve()
|
|
308
|
+
}, 2_000)
|
|
309
|
+
timer.unref()
|
|
310
|
+
child.once('exit', () => {
|
|
311
|
+
clearTimeout(timer)
|
|
312
|
+
resolve()
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
this.#failAll()
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
type ControlMessage = Record<string, unknown> & { type: string; requestId?: string }
|
|
320
|
+
|
|
321
|
+
class ControlFrameDecoder {
|
|
322
|
+
readonly #maxBytes: number
|
|
323
|
+
#buffer = Buffer.alloc(0)
|
|
324
|
+
|
|
325
|
+
constructor(maxBytes: number) {
|
|
326
|
+
this.#maxBytes = maxBytes
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
push(chunk: Buffer): unknown[] {
|
|
330
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk])
|
|
331
|
+
const frames: unknown[] = []
|
|
332
|
+
while (this.#buffer.byteLength >= 4) {
|
|
333
|
+
const length = this.#buffer.readUInt32BE(0)
|
|
334
|
+
if (length > this.#maxBytes) throw new Error('Control frame exceeds limit')
|
|
335
|
+
if (this.#buffer.byteLength < 4 + length) break
|
|
336
|
+
const payload = this.#buffer.subarray(4, 4 + length)
|
|
337
|
+
this.#buffer = this.#buffer.subarray(4 + length)
|
|
338
|
+
frames.push(JSON.parse(payload.toString('utf8')) as unknown)
|
|
339
|
+
}
|
|
340
|
+
return frames
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function controlError(
|
|
345
|
+
requestId: string | undefined,
|
|
346
|
+
code: string,
|
|
347
|
+
message: string
|
|
348
|
+
): Record<string, unknown> {
|
|
349
|
+
return { type: 'error', requestId: requestId ?? '', code, message }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sameControlIdentity(left: unknown, right: WindowsControlIdentity): boolean {
|
|
353
|
+
if (left === null || typeof left !== 'object') return false
|
|
354
|
+
const identity = left as Record<string, unknown>
|
|
355
|
+
return (
|
|
356
|
+
identity.osIdentity === right.osIdentity &&
|
|
357
|
+
identity.graphicalSessionId === right.graphicalSessionId
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Control-protocol server for Windows. The relay calls onConnection only after
|
|
363
|
+
* native DACL, locality, SID, logon-session, Windows-session, PID and integrity
|
|
364
|
+
* verification. This layer still requires a versioned handshake before it
|
|
365
|
+
* parses a request envelope or invokes the broker handler.
|
|
366
|
+
*/
|
|
367
|
+
export class WindowsRelayControlServer {
|
|
368
|
+
readonly #options: WindowsRelayControlServerOptions
|
|
369
|
+
readonly #relay: WindowsControlRelay
|
|
370
|
+
readonly #maxFrameBytes: number
|
|
371
|
+
readonly #maxFrames: number
|
|
372
|
+
readonly #now: () => number
|
|
373
|
+
#requestCount = 0
|
|
374
|
+
#closed = false
|
|
375
|
+
|
|
376
|
+
constructor(options: WindowsRelayControlServerOptions) {
|
|
377
|
+
this.#options = options
|
|
378
|
+
this.#maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_CONTROL_MESSAGE_BYTES
|
|
379
|
+
this.#maxFrames = options.maxFramesPerConnection ?? 1024
|
|
380
|
+
this.#now = options.now ?? Date.now
|
|
381
|
+
this.#relay = (
|
|
382
|
+
options.createRelay ?? ((relayOptions) => new NativeWindowsControlRelay(relayOptions))
|
|
383
|
+
)({
|
|
384
|
+
...options.relay,
|
|
385
|
+
onConnection: (connection) => this.#accept(connection)
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
get requestCount(): number {
|
|
390
|
+
return this.#requestCount
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async start(): Promise<void> {
|
|
394
|
+
if (this.#closed) throw new Error('Windows control server is closed')
|
|
395
|
+
await this.#relay.start()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#accept(connection: WindowsControlConnection): void {
|
|
399
|
+
const decoder = new ControlFrameDecoder(this.#maxFrameBytes)
|
|
400
|
+
let authenticated = false
|
|
401
|
+
let frameCount = 0
|
|
402
|
+
let chain = Promise.resolve()
|
|
403
|
+
let closed = false
|
|
404
|
+
|
|
405
|
+
const close = (): void => {
|
|
406
|
+
if (closed) return
|
|
407
|
+
closed = true
|
|
408
|
+
try {
|
|
409
|
+
connection.close()
|
|
410
|
+
} catch {
|
|
411
|
+
// The relay already failed closed; there is no usable peer left to notify.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const send = (message: unknown): void => {
|
|
415
|
+
if (!closed) connection.write(encodeFrame(message, this.#maxFrameBytes))
|
|
416
|
+
}
|
|
417
|
+
const sendAndClose = (message: unknown): void => {
|
|
418
|
+
send(message)
|
|
419
|
+
close()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
connection.onClose(() => {
|
|
423
|
+
closed = true
|
|
424
|
+
})
|
|
425
|
+
connection.onData((chunk) => {
|
|
426
|
+
chain = chain
|
|
427
|
+
.then(async () => {
|
|
428
|
+
for (const raw of decoder.push(chunk)) {
|
|
429
|
+
frameCount += 1
|
|
430
|
+
if (frameCount > this.#maxFrames) throw new Error('Control frame limit exceeded')
|
|
431
|
+
if (raw === null || typeof raw !== 'object' || !('type' in raw)) {
|
|
432
|
+
throw new Error('Malformed control message')
|
|
433
|
+
}
|
|
434
|
+
const message = raw as ControlMessage
|
|
435
|
+
if (!authenticated) {
|
|
436
|
+
if (message.type !== 'handshake') {
|
|
437
|
+
sendAndClose(
|
|
438
|
+
controlError(message.requestId, 'handshake_required', 'Handshake is required')
|
|
439
|
+
)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
authenticated = this.#authenticate(message)
|
|
443
|
+
if (!authenticated) {
|
|
444
|
+
sendAndClose(
|
|
445
|
+
controlError(message.requestId, 'peer_rejected', 'Peer authentication failed')
|
|
446
|
+
)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
const offeredVersions = (message as Record<string, unknown>).versions
|
|
450
|
+
if (offeredVersions === null || typeof offeredVersions !== 'object') {
|
|
451
|
+
authenticated = false
|
|
452
|
+
sendAndClose(
|
|
453
|
+
controlError(message.requestId, 'version_incompatible', 'Versions are required')
|
|
454
|
+
)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
const negotiation = negotiateVersionHandshake(offeredVersions as ContractVersions)
|
|
458
|
+
if (!negotiation.ok) {
|
|
459
|
+
authenticated = false
|
|
460
|
+
sendAndClose(
|
|
461
|
+
controlError(message.requestId, negotiation.error.code, negotiation.error.message)
|
|
462
|
+
)
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
send({ type: 'handshake-ok', requestId: message.requestId })
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
if (message.type !== 'request') {
|
|
469
|
+
sendAndClose(
|
|
470
|
+
controlError(message.requestId, 'invalid_message', 'Expected a request message')
|
|
471
|
+
)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
// oxlint-disable-next-line no-await-in-loop -- one connection preserves frame order.
|
|
475
|
+
await this.#handleRequest(connection.peer, message, send)
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
.catch(() => close())
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#authenticate(message: ControlMessage): boolean {
|
|
483
|
+
const record = message as Record<string, unknown>
|
|
484
|
+
return (
|
|
485
|
+
typeof message.requestId === 'string' &&
|
|
486
|
+
record.token === '' &&
|
|
487
|
+
sameControlIdentity(record.identity, this.#options.identity)
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async #handleRequest(
|
|
492
|
+
peer: WindowsControlPeer,
|
|
493
|
+
message: ControlMessage,
|
|
494
|
+
send: (message: unknown) => void
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
if (
|
|
497
|
+
typeof message.requestId !== 'string' ||
|
|
498
|
+
typeof message.deadlineAt !== 'number' ||
|
|
499
|
+
!Number.isFinite(message.deadlineAt)
|
|
500
|
+
) {
|
|
501
|
+
send(
|
|
502
|
+
controlError(message.requestId, 'invalid_request', 'Request ID and deadline are required')
|
|
503
|
+
)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
if (message.deadlineAt <= this.#now()) {
|
|
507
|
+
send(controlError(message.requestId, 'timeout', 'Request deadline elapsed'))
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
const deadlineAt = message.deadlineAt
|
|
511
|
+
this.#requestCount += 1
|
|
512
|
+
let timer: NodeJS.Timeout | undefined
|
|
513
|
+
try {
|
|
514
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
515
|
+
timer = setTimeout(
|
|
516
|
+
() =>
|
|
517
|
+
reject(
|
|
518
|
+
Object.assign(new Error('Control request deadline elapsed'), { code: 'timeout' })
|
|
519
|
+
),
|
|
520
|
+
deadlineAt - this.#now()
|
|
521
|
+
)
|
|
522
|
+
timer.unref()
|
|
523
|
+
})
|
|
524
|
+
const result = await Promise.race([
|
|
525
|
+
this.#options.handler({
|
|
526
|
+
requestId: message.requestId,
|
|
527
|
+
payload: message.payload,
|
|
528
|
+
deadlineAt,
|
|
529
|
+
peer
|
|
530
|
+
}),
|
|
531
|
+
timeout
|
|
532
|
+
])
|
|
533
|
+
send({ type: 'response', requestId: message.requestId, result })
|
|
534
|
+
} catch (cause) {
|
|
535
|
+
const code =
|
|
536
|
+
typeof cause === 'object' && cause !== null && 'code' in cause
|
|
537
|
+
? String(cause.code)
|
|
538
|
+
: 'request_failed'
|
|
539
|
+
send(
|
|
540
|
+
controlError(
|
|
541
|
+
message.requestId,
|
|
542
|
+
code,
|
|
543
|
+
cause instanceof Error ? cause.message : 'Request failed'
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
} finally {
|
|
547
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async close(): Promise<void> {
|
|
552
|
+
if (this.#closed) return
|
|
553
|
+
this.#closed = true
|
|
554
|
+
await this.#relay.close()
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function createWindowsControlServer(
|
|
559
|
+
options: WindowsRelayControlServerOptions
|
|
560
|
+
): WindowsRelayControlServer {
|
|
561
|
+
return new WindowsRelayControlServer(options)
|
|
562
|
+
}
|