@vextrix3d/machine-agent 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ import { connect } from 'node:net';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ export const CAMERA_GATEWAY_PORT = 1984;
8
+ export const CAMERA_REGISTRY_DIR = join(homedir(), '.local', 'state', 'machine-agent', 'cameras');
9
+ const PRINTER_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
10
+ export const CAMERA_RELAY_HEADER = 'x-vextrix3d-camera';
11
+ const CAMERA_RELAY_TOKEN_PAST_SECS = 120;
12
+ const CAMERA_RELAY_TOKEN_FUTURE_SECS = 180;
13
+ export const checkCameraRelayToken = (token, printerId, key, nowSecs) => {
14
+ const match = token?.match(/^(\d{1,12})\.([A-Za-z0-9_-]{43})$/);
15
+ if (!match) {
16
+ return 'invalid';
17
+ }
18
+ const expiresAt = Number(match[1]);
19
+ const expected = createHmac('sha256', key).update(`${printerId}.${expiresAt}`).digest();
20
+ const signature = Buffer.from(match[2], 'base64url');
21
+ if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) {
22
+ return 'invalid';
23
+ }
24
+ const isInTime = expiresAt >= nowSecs - CAMERA_RELAY_TOKEN_PAST_SECS &&
25
+ expiresAt <= nowSecs + CAMERA_RELAY_TOKEN_FUTURE_SECS;
26
+ return isInTime ? 'valid' : 'mistimed';
27
+ };
28
+ export const createCameraRegistry = (dir) => {
29
+ const resolvePath = (printerId) => join(dir, `${printerId}.json`);
30
+ return {
31
+ register: (printerId, port, key) => {
32
+ if (!PRINTER_ID_PATTERN.test(printerId)) {
33
+ throw new Error(`"${printerId}" is not a printer id the camera registry can hold`);
34
+ }
35
+ mkdirSync(dir, {
36
+ recursive: true,
37
+ });
38
+ chmodSync(dir, 0o700);
39
+ const path = resolvePath(printerId);
40
+ const pendingPath = `${path}.${process.pid}`;
41
+ writeFileSync(pendingPath, JSON.stringify({ port, key }), {
42
+ mode: 0o600,
43
+ });
44
+ renameSync(pendingPath, path);
45
+ },
46
+ unregister: printerId => {
47
+ if (PRINTER_ID_PATTERN.test(printerId)) {
48
+ rmSync(resolvePath(printerId), {
49
+ force: true,
50
+ });
51
+ }
52
+ },
53
+ lookup: printerId => {
54
+ if (!PRINTER_ID_PATTERN.test(printerId)) {
55
+ return null;
56
+ }
57
+ try {
58
+ const { port, key } = JSON.parse(readFileSync(resolvePath(printerId), 'utf8'));
59
+ if (!Number.isInteger(port)) {
60
+ return null;
61
+ }
62
+ return {
63
+ port,
64
+ key: typeof key === 'string' && key ? key : null,
65
+ };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ },
71
+ };
72
+ };
73
+ const refuse = (socket, status) => {
74
+ socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, () => socket.destroy());
75
+ };
76
+ const buildRequestHead = (request) => {
77
+ const lines = [`${request.method} ${request.url} HTTP/${request.httpVersion}`];
78
+ for (let index = 0; index < request.rawHeaders.length; index += 2) {
79
+ if (request.rawHeaders[index].toLowerCase() !== CAMERA_RELAY_HEADER) {
80
+ lines.push(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`);
81
+ }
82
+ }
83
+ return `${lines.join('\r\n')}\r\n\r\n`;
84
+ };
85
+ export const createCameraGateway = ({ port, registry, reusePort, retryDelayMs = 30_000, }) => {
86
+ const sockets = new Set();
87
+ let server = null;
88
+ let retryTimeoutId;
89
+ let hasReportedTaken = false;
90
+ let isStopping = false;
91
+ const pass = (request, socket, head) => {
92
+ sockets.add(socket);
93
+ socket.once('close', () => sockets.delete(socket));
94
+ socket.on('error', () => socket.destroy());
95
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
96
+ const printerId = url.searchParams.get('src') ?? '';
97
+ if (url.pathname !== '/api/ws' || request.headers.upgrade?.toLowerCase() !== 'websocket') {
98
+ refuse(socket, '404 Not Found');
99
+ return;
100
+ }
101
+ const entry = registry.lookup(printerId);
102
+ if (!entry) {
103
+ console.error(`[camera] A viewer asked for printer ${JSON.stringify(printerId)}, which no agent on this machine relays`);
104
+ refuse(socket, '404 Not Found');
105
+ return;
106
+ }
107
+ const token = request.headers[CAMERA_RELAY_HEADER];
108
+ const check = entry.key === null
109
+ ? 'invalid'
110
+ : checkCameraRelayToken(typeof token === 'string' ? token : undefined, printerId, entry.key, Date.now() / 1000);
111
+ if (check === 'mistimed') {
112
+ console.error(`[camera] A viewer asked for printer ${printerId} with a relay token that has expired or is not yet valid by this computer's clock - is the clock more than two minutes off? Keep it set by NTP`);
113
+ }
114
+ else if (check === 'invalid') {
115
+ console.error(`[camera] A viewer asked for printer ${printerId} without a valid relay token${entry.key === null ? ' - its agent has no relay key; restart it' : ''}`);
116
+ }
117
+ if (check !== 'valid') {
118
+ refuse(socket, '401 Unauthorized');
119
+ return;
120
+ }
121
+ const go2rtcPort = entry.port;
122
+ const go2rtc = connect(go2rtcPort, '127.0.0.1');
123
+ sockets.add(go2rtc);
124
+ go2rtc.once('close', () => sockets.delete(go2rtc));
125
+ let isConnected = false;
126
+ const close = () => {
127
+ socket.destroy();
128
+ go2rtc.destroy();
129
+ };
130
+ socket.once('close', close);
131
+ go2rtc.on('error', error => {
132
+ if (isConnected) {
133
+ close();
134
+ return;
135
+ }
136
+ console.error(`[camera] The go2rtc of printer ${printerId} is not answering on 127.0.0.1:${go2rtcPort}:`, error.message);
137
+ refuse(socket, '502 Bad Gateway');
138
+ });
139
+ go2rtc.once('connect', () => {
140
+ isConnected = true;
141
+ go2rtc.once('close', close);
142
+ go2rtc.write(buildRequestHead(request));
143
+ if (head.length) {
144
+ go2rtc.write(head);
145
+ }
146
+ socket.pipe(go2rtc).pipe(socket);
147
+ });
148
+ };
149
+ const listen = () => new Promise(resolve => {
150
+ const nextServer = createServer((_request, response) => {
151
+ response.writeHead(404).end();
152
+ });
153
+ nextServer.on('upgrade', pass);
154
+ nextServer.once('error', (error) => {
155
+ if (error.code !== 'EADDRINUSE') {
156
+ console.error(`[camera] Could not take camera requests on 127.0.0.1:${port}:`, error);
157
+ resolve(false);
158
+ return;
159
+ }
160
+ if (!hasReportedTaken) {
161
+ hasReportedTaken = true;
162
+ console.error(`[camera] Port ${port}, where this machine's agents take camera requests together, is held by something else - the printer-camera pm2 process from before? (\`yarn pm2 delete printer-camera\`). Trying again every ${retryDelayMs / 1000}s`);
163
+ }
164
+ if (!isStopping) {
165
+ retryTimeoutId = setTimeout(() => void listen(), retryDelayMs);
166
+ }
167
+ resolve(false);
168
+ });
169
+ nextServer.listen({
170
+ host: '127.0.0.1',
171
+ port,
172
+ reusePort,
173
+ }, () => {
174
+ if (isStopping) {
175
+ nextServer.close();
176
+ resolve(false);
177
+ return;
178
+ }
179
+ server = nextServer;
180
+ console.log(`[camera] Taking this machine's camera requests on 127.0.0.1:${port}`);
181
+ resolve(true);
182
+ });
183
+ });
184
+ return {
185
+ start: listen,
186
+ stop: async () => {
187
+ isStopping = true;
188
+ clearTimeout(retryTimeoutId);
189
+ for (const socket of sockets) {
190
+ socket.destroy();
191
+ }
192
+ const runningServer = server;
193
+ if (runningServer) {
194
+ await new Promise(resolve => {
195
+ runningServer.close(() => resolve());
196
+ runningServer.closeAllConnections();
197
+ });
198
+ }
199
+ },
200
+ };
201
+ };
@@ -0,0 +1,90 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ import { CameraJpegConnection } from './camera-jpeg-connection.js';
5
+ const BOUNDARY = 'frame';
6
+ const FRAMES_MEASURED = 10;
7
+ export const createCameraJpegBridge = ({ host, port, serialNumber, accessCode, caFile, printerLabel, }) => {
8
+ const ca = readFileSync(caFile);
9
+ const path = `/${randomBytes(16).toString('hex')}`;
10
+ const connections = new Set();
11
+ let reportedFailure = null;
12
+ let hasReportedRate = false;
13
+ const reportRate = (firstFrameAt, bytes) => {
14
+ hasReportedRate = true;
15
+ const intervalS = (Date.now() - firstFrameAt) / 1000 / (FRAMES_MEASURED - 1);
16
+ console.log(`[camera] "${printerLabel}" sends its camera as a JPEG frame every ${intervalS.toFixed(1)}s, ${Math.round(bytes / FRAMES_MEASURED / 1024)} KB each`);
17
+ };
18
+ const relay = (request, response) => {
19
+ if (request.method !== 'GET' || request.url !== path) {
20
+ response.writeHead(404).end();
21
+ return;
22
+ }
23
+ let frames = 0;
24
+ let bytes = 0;
25
+ let firstFrameAt = 0;
26
+ const connection = new CameraJpegConnection({
27
+ host,
28
+ port,
29
+ serialNumber,
30
+ accessCode,
31
+ ca,
32
+ onFrame: frame => {
33
+ if (!frames) {
34
+ reportedFailure = null;
35
+ firstFrameAt = Date.now();
36
+ response.writeHead(200, {
37
+ 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
38
+ 'Cache-Control': 'no-store',
39
+ });
40
+ }
41
+ frames += 1;
42
+ bytes += frame.length;
43
+ if (frames === FRAMES_MEASURED && !hasReportedRate) {
44
+ reportRate(firstFrameAt, bytes);
45
+ }
46
+ if (response.writableNeedDrain) {
47
+ return;
48
+ }
49
+ response.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
50
+ response.write(frame);
51
+ response.write('\r\n');
52
+ },
53
+ onFailure: error => {
54
+ if (!response.headersSent) {
55
+ response.writeHead(502).end();
56
+ }
57
+ if (error.message === reportedFailure) {
58
+ return;
59
+ }
60
+ reportedFailure = error.message;
61
+ console.error(`[camera] The camera of "${printerLabel}" at ${host}:${port} was not opened: ${error.message}`);
62
+ },
63
+ onClose: () => {
64
+ connections.delete(connection);
65
+ response.destroy();
66
+ },
67
+ });
68
+ connections.add(connection);
69
+ response.once('close', () => connection.close());
70
+ };
71
+ const server = createServer(relay);
72
+ const streamUrl = new Promise((resolve, reject) => {
73
+ server.once('error', reject);
74
+ server.listen({
75
+ host: '127.0.0.1',
76
+ port: 0,
77
+ }, () => {
78
+ const address = server.address();
79
+ resolve(`http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}${path}`);
80
+ });
81
+ });
82
+ return {
83
+ streamUrl,
84
+ close: () => new Promise(resolve => {
85
+ connections.forEach(connection => connection.close());
86
+ server.close(() => resolve());
87
+ server.closeAllConnections();
88
+ }),
89
+ };
90
+ };
@@ -0,0 +1,124 @@
1
+ import { connect } from 'node:tls';
2
+ import { checkPrinterCertificate } from './camera-stream-connection.js';
3
+ export const JPEG_CAMERA_PORT = 6000;
4
+ const JPEG_CAMERA_MODELS = {
5
+ '01S': 'P1P',
6
+ '01P': 'P1S',
7
+ '030': 'A1 mini',
8
+ '039': 'A1',
9
+ '26A': 'A2L',
10
+ };
11
+ export const readJpegCamera = (serialNumber, host) => {
12
+ const model = JPEG_CAMERA_MODELS[serialNumber.slice(0, 3).toUpperCase()];
13
+ return model
14
+ ? {
15
+ model,
16
+ host,
17
+ port: JPEG_CAMERA_PORT,
18
+ }
19
+ : null;
20
+ };
21
+ export const buildJpegCameraAuthPacket = (accessCode) => {
22
+ const packet = Buffer.alloc(80);
23
+ packet.writeUInt32LE(0x40, 0);
24
+ packet.writeUInt32LE(0x3000, 4);
25
+ packet.write('bblp', 16, 32, 'ascii');
26
+ packet.write(accessCode, 48, 32, 'ascii');
27
+ return packet;
28
+ };
29
+ const FRAME_HEADER_SIZE = 16;
30
+ const MAX_FRAME_SIZE = 4 * 1024 * 1024;
31
+ export const createJpegFrameReader = (onFrame) => {
32
+ const chunks = [];
33
+ let buffered = 0;
34
+ let frameSize = null;
35
+ const take = (size) => {
36
+ const joined = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, buffered);
37
+ chunks.length = 0;
38
+ if (joined.length > size) {
39
+ chunks.push(joined.subarray(size));
40
+ }
41
+ buffered -= size;
42
+ return joined.subarray(0, size);
43
+ };
44
+ return (chunk) => {
45
+ chunks.push(chunk);
46
+ buffered += chunk.length;
47
+ while (true) {
48
+ if (frameSize === null) {
49
+ if (buffered < FRAME_HEADER_SIZE) {
50
+ return;
51
+ }
52
+ const size = take(FRAME_HEADER_SIZE).readUInt32LE(0);
53
+ if (size < 4 || size > MAX_FRAME_SIZE) {
54
+ throw new Error(`it sent a frame of ${size} bytes`);
55
+ }
56
+ frameSize = size;
57
+ }
58
+ if (buffered < frameSize) {
59
+ return;
60
+ }
61
+ const frame = take(frameSize);
62
+ frameSize = null;
63
+ const isJpeg = frame[0] === 0xff &&
64
+ frame[1] === 0xd8 &&
65
+ frame[frame.length - 2] === 0xff &&
66
+ frame[frame.length - 1] === 0xd9;
67
+ if (!isJpeg) {
68
+ throw new Error('it sent a frame that is not a JPEG');
69
+ }
70
+ onFrame(frame);
71
+ }
72
+ };
73
+ };
74
+ export class CameraJpegConnection {
75
+ printer;
76
+ hasFrame = false;
77
+ isClosed = false;
78
+ constructor({ host, port, serialNumber, accessCode, ca, onFrame, onFailure, onClose, }) {
79
+ this.printer = connect({
80
+ host,
81
+ port,
82
+ servername: serialNumber,
83
+ ca,
84
+ checkServerIdentity: checkPrinterCertificate(serialNumber),
85
+ maxVersion: 'TLSv1.2',
86
+ });
87
+ const fail = (error) => {
88
+ if (!this.isClosed) {
89
+ onFailure(error);
90
+ this.close();
91
+ }
92
+ };
93
+ const readFrames = createJpegFrameReader(frame => {
94
+ this.hasFrame = true;
95
+ onFrame(frame);
96
+ });
97
+ this.printer.once('secureConnect', () => {
98
+ this.printer.write(buildJpegCameraAuthPacket(accessCode));
99
+ });
100
+ this.printer.on('data', (chunk) => {
101
+ try {
102
+ readFrames(chunk);
103
+ }
104
+ catch (error) {
105
+ fail(error);
106
+ }
107
+ });
108
+ this.printer.on('error', fail);
109
+ this.printer.once('close', () => {
110
+ if (!this.hasFrame) {
111
+ fail(new Error('the printer closed the connection before sending a frame, as it does where the access code is wrong'));
112
+ }
113
+ this.close();
114
+ onClose();
115
+ });
116
+ }
117
+ close() {
118
+ if (this.isClosed) {
119
+ return;
120
+ }
121
+ this.isClosed = true;
122
+ this.printer.destroy();
123
+ }
124
+ }