|
|
@@ -0,0 +1,177 @@
|
|
|
+import crypto from 'node:crypto';
|
|
|
+import path from 'node:path';
|
|
|
+import { createRequire } from 'node:module';
|
|
|
+import { PACKAGE_ROOT } from './config-loader.mjs';
|
|
|
+
|
|
|
+const require = createRequire(import.meta.url);
|
|
|
+const relayConfig = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'relay-config.js'));
|
|
|
+const { readQiweiGuid } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'credentials.js'));
|
|
|
+export function decryptPayload(encryptedPayload, privateKeyPem) {
|
|
|
+ const key = privateKeyPem && typeof privateKeyPem === 'object' && privateKeyPem.type === 'private'
|
|
|
+ ? privateKeyPem
|
|
|
+ : crypto.createPrivateKey(privateKeyPem);
|
|
|
+ if (String(encryptedPayload).startsWith('v2:')) {
|
|
|
+ const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
|
|
|
+ const aesKey = crypto.privateDecrypt(
|
|
|
+ { key, oaepHash: 'sha256' },
|
|
|
+ Buffer.from(envelope.key, 'base64'),
|
|
|
+ );
|
|
|
+ const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(envelope.iv, 'base64'));
|
|
|
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
|
|
|
+ return Buffer.concat([
|
|
|
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
|
|
|
+ decipher.final(),
|
|
|
+ ]).toString('utf8');
|
|
|
+ }
|
|
|
+ return crypto.privateDecrypt(
|
|
|
+ { key, oaepHash: 'sha256' },
|
|
|
+ Buffer.from(encryptedPayload, 'base64'),
|
|
|
+ ).toString('utf8');
|
|
|
+}
|
|
|
+
|
|
|
+function resolveRelayContext(explicitGuid = '') {
|
|
|
+ return {
|
|
|
+ baseUrl: relayConfig.getRelayBaseUrl(),
|
|
|
+ apiSecret: relayConfig.getTenantApiSecret(),
|
|
|
+ privateKey: relayConfig.getRelayPrivateKey(),
|
|
|
+ tenantId: relayConfig.getTenantId(),
|
|
|
+ guid: String(explicitGuid || relayConfig.getRelayDeviceGuid() || readQiweiGuid() || '').trim(),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function ackEvents(context, eventIds, signal, fetchImpl = fetch) {
|
|
|
+ if (!eventIds.length) return 0;
|
|
|
+ const response = await fetchImpl(`${context.baseUrl}/api/relay/ack`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ Authorization: `Bearer ${context.apiSecret}`,
|
|
|
+ },
|
|
|
+ body: JSON.stringify({ guid: context.guid, eventIds }),
|
|
|
+ signal,
|
|
|
+ });
|
|
|
+ if (!response.ok) throw new Error(`Relay ACK failed: HTTP ${response.status}`);
|
|
|
+ const data = await response.json();
|
|
|
+ return Number(data.ackedCount || eventIds.length);
|
|
|
+}
|
|
|
+
|
|
|
+export class EnterpriseRelayRuntime {
|
|
|
+ constructor({ config, guid = '', onState = () => {}, processor = null, fetchImpl = fetch }) {
|
|
|
+ this.config = config;
|
|
|
+ this.guid = guid;
|
|
|
+ this.onState = onState;
|
|
|
+ this.processor = processor;
|
|
|
+ this.fetchImpl = fetchImpl;
|
|
|
+ this.running = false;
|
|
|
+ this.abortController = null;
|
|
|
+ this.loopPromise = null;
|
|
|
+ this.cancelWait = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ wait(ms) {
|
|
|
+ return new Promise(resolve => {
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ this.cancelWait = null;
|
|
|
+ resolve();
|
|
|
+ }, ms);
|
|
|
+ this.cancelWait = () => {
|
|
|
+ clearTimeout(timer);
|
|
|
+ this.cancelWait = null;
|
|
|
+ resolve();
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async pollOnce(context) {
|
|
|
+ const processRelayEnvelope = this.processor || (await import('./processor-bridge.mjs')).processRelayEnvelope;
|
|
|
+ this.abortController = new AbortController();
|
|
|
+ const response = await this.fetchImpl(`${context.baseUrl}/api/relay/poll`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ Authorization: `Bearer ${context.apiSecret}`,
|
|
|
+ },
|
|
|
+ body: JSON.stringify({
|
|
|
+ guid: context.guid,
|
|
|
+ batchSize: this.config.batchSize,
|
|
|
+ waitMs: this.config.waitMs,
|
|
|
+ }),
|
|
|
+ signal: this.abortController.signal,
|
|
|
+ });
|
|
|
+ if (!response.ok) throw new Error(`Relay poll failed: HTTP ${response.status}`);
|
|
|
+ const data = await response.json();
|
|
|
+ const events = Array.isArray(data.events) ? data.events : [];
|
|
|
+ const processedIds = [];
|
|
|
+ const failures = [];
|
|
|
+
|
|
|
+ for (const event of events) {
|
|
|
+ try {
|
|
|
+ const decrypted = decryptPayload(event.encryptedPayload, context.privateKey);
|
|
|
+ const payload = JSON.parse(decrypted);
|
|
|
+ const envelope = {
|
|
|
+ code: 0,
|
|
|
+ msg: 'from-relay',
|
|
|
+ data: Array.isArray(payload) ? payload : [payload],
|
|
|
+ __rawBody: decrypted,
|
|
|
+ };
|
|
|
+ await processRelayEnvelope(envelope);
|
|
|
+ processedIds.push(event.eventId);
|
|
|
+ } catch (error) {
|
|
|
+ failures.push({ eventId: event.eventId, message: error.message });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const acked = await ackEvents(context, processedIds, this.abortController.signal, this.fetchImpl);
|
|
|
+ if (failures.length) throw new Error(`Relay retained ${failures.length} failed event(s): ${failures[0].message}`);
|
|
|
+ return { fetched: events.length, acked };
|
|
|
+ }
|
|
|
+
|
|
|
+ async loop() {
|
|
|
+ let backoff = this.config.retryMinMs;
|
|
|
+ while (this.running) {
|
|
|
+ const context = resolveRelayContext(this.guid);
|
|
|
+ const missing = ['apiSecret', 'privateKey', 'tenantId', 'guid'].filter(key => !context[key]);
|
|
|
+ if (missing.length) {
|
|
|
+ this.onState({ enterpriseRelay: { status: 'waiting', lastError: `Missing ${missing.join(', ')}` } });
|
|
|
+ await this.wait(Math.min(this.config.retryMaxMs, Math.max(5000, backoff)));
|
|
|
+ backoff = Math.min(backoff * 2, this.config.retryMaxMs);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await this.pollOnce(context);
|
|
|
+ this.onState({
|
|
|
+ enterpriseRelay: {
|
|
|
+ status: 'running',
|
|
|
+ guid: context.guid,
|
|
|
+ lastFetched: result.fetched,
|
|
|
+ lastAcked: result.acked,
|
|
|
+ lastPollAt: new Date().toISOString(),
|
|
|
+ lastError: '',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ backoff = this.config.retryMinMs;
|
|
|
+ } catch (error) {
|
|
|
+ if (!this.running && error.name === 'AbortError') break;
|
|
|
+ this.onState({ enterpriseRelay: { status: 'error', lastError: error.message } });
|
|
|
+ await this.wait(backoff);
|
|
|
+ backoff = Math.min(backoff * 2, this.config.retryMaxMs);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ start() {
|
|
|
+ if (this.running) return;
|
|
|
+ this.running = true;
|
|
|
+ this.onState({ enterpriseRelay: { status: 'starting', lastError: '' } });
|
|
|
+ this.loopPromise = this.loop();
|
|
|
+ }
|
|
|
+
|
|
|
+ async stop() {
|
|
|
+ this.running = false;
|
|
|
+ if (this.cancelWait) this.cancelWait();
|
|
|
+ this.abortController?.abort();
|
|
|
+ this.onState({ enterpriseRelay: { status: 'stopped' } });
|
|
|
+ await this.loopPromise;
|
|
|
+ }
|
|
|
+}
|