@omnixal/openclaw-nats-plugin 0.2.8 → 0.2.10
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Controller, BaseController, Subscribe, OnQueueReady, type Message } from '@onebun/core';
|
|
2
2
|
import { PipelineService } from '../pre-handlers/pipeline.service';
|
|
3
|
-
import { GatewayClientService } from '../gateway/gateway-client.service';
|
|
3
|
+
import { GatewayClientService, GatewayRpcError } from '../gateway/gateway-client.service';
|
|
4
4
|
import { PendingService } from '../pending/pending.service';
|
|
5
5
|
import { RouterService } from '../router/router.service';
|
|
6
6
|
import { MetricsService } from '../metrics/metrics.service';
|
|
@@ -73,6 +73,13 @@ export class ConsumerController extends BaseController {
|
|
|
73
73
|
await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify({ eventId: envelope.id, target: route.target }));
|
|
74
74
|
} catch (routeErr) {
|
|
75
75
|
await this.logService.logError('route', route.id, envelope.subject, routeErr);
|
|
76
|
+
// Gateway rejected the request (e.g. missing scope) — store in pending, don't nack
|
|
77
|
+
if (routeErr instanceof GatewayRpcError) {
|
|
78
|
+
this.logger.error(`Gateway rejected event ${envelope.id}: ${routeErr.errorCode} — ${routeErr.errorMessage}`);
|
|
79
|
+
await this.pendingService.addPending(envelope);
|
|
80
|
+
await message.ack();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
76
83
|
throw routeErr;
|
|
77
84
|
}
|
|
78
85
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface DeviceIdentity {
|
|
6
|
+
deviceId: string;
|
|
7
|
+
publicKeyPem: string;
|
|
8
|
+
privateKeyPem: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StoredIdentity {
|
|
12
|
+
version: 1;
|
|
13
|
+
deviceId: string;
|
|
14
|
+
publicKeyPem: string;
|
|
15
|
+
privateKeyPem: string;
|
|
16
|
+
createdAtMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SignChallengeParams {
|
|
20
|
+
deviceId: string;
|
|
21
|
+
clientId: string;
|
|
22
|
+
clientMode: string;
|
|
23
|
+
role: string;
|
|
24
|
+
scopes: string[];
|
|
25
|
+
signedAtMs: number;
|
|
26
|
+
token: string;
|
|
27
|
+
nonce: string;
|
|
28
|
+
platform?: string;
|
|
29
|
+
deviceFamily?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ed25519 SPKI DER prefix (12 bytes) before the 32-byte raw public key */
|
|
33
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
34
|
+
|
|
35
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
36
|
+
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Extract raw 32-byte Ed25519 public key from PEM-encoded SPKI */
|
|
40
|
+
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
41
|
+
const key = crypto.createPublicKey(publicKeyPem);
|
|
42
|
+
const spki = key.export({ type: 'spki', format: 'der' }) as Buffer;
|
|
43
|
+
if (
|
|
44
|
+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
45
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
46
|
+
) {
|
|
47
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
48
|
+
}
|
|
49
|
+
return spki;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Derive deviceId as SHA-256(rawPublicKey).hex — matches OpenClaw's deriveDeviceIdFromPublicKey */
|
|
53
|
+
function fingerprintPublicKey(publicKeyPem: string): string {
|
|
54
|
+
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
55
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Normalize metadata for auth payload — ASCII lowercase + trim (matches OpenClaw's normalizeDeviceMetadataForAuth) */
|
|
59
|
+
function normalizeMetadata(value?: string | null): string {
|
|
60
|
+
if (typeof value !== 'string') return '';
|
|
61
|
+
const trimmed = value.trim();
|
|
62
|
+
if (!trimmed) return '';
|
|
63
|
+
return trimmed.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Generate a new Ed25519 keypair and derive device identity */
|
|
67
|
+
export function generateIdentity(): DeviceIdentity {
|
|
68
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
69
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
70
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
71
|
+
const deviceId = fingerprintPublicKey(publicKeyPem);
|
|
72
|
+
return { deviceId, publicKeyPem, privateKeyPem };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load existing device identity from file, or generate and persist a new one */
|
|
76
|
+
export function loadOrCreateIdentity(filePath: string): DeviceIdentity {
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(filePath)) {
|
|
79
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
80
|
+
const parsed = JSON.parse(raw) as StoredIdentity;
|
|
81
|
+
if (
|
|
82
|
+
parsed?.version === 1 &&
|
|
83
|
+
typeof parsed.deviceId === 'string' &&
|
|
84
|
+
typeof parsed.publicKeyPem === 'string' &&
|
|
85
|
+
typeof parsed.privateKeyPem === 'string'
|
|
86
|
+
) {
|
|
87
|
+
// Re-derive deviceId to handle corrupted stored IDs
|
|
88
|
+
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
|
89
|
+
return {
|
|
90
|
+
deviceId: derivedId,
|
|
91
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
92
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// fall through to regenerate
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const identity = generateIdentity();
|
|
101
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
102
|
+
const stored: StoredIdentity = {
|
|
103
|
+
version: 1,
|
|
104
|
+
deviceId: identity.deviceId,
|
|
105
|
+
publicKeyPem: identity.publicKeyPem,
|
|
106
|
+
privateKeyPem: identity.privateKeyPem,
|
|
107
|
+
createdAtMs: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
110
|
+
return identity;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get base64url-encoded raw public key (for the connect frame device.publicKey field) */
|
|
114
|
+
export function publicKeyToBase64Url(publicKeyPem: string): string {
|
|
115
|
+
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build v3 signature payload and sign it with Ed25519 private key.
|
|
120
|
+
* Returns base64url-encoded signature.
|
|
121
|
+
*
|
|
122
|
+
* v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
|
|
123
|
+
*/
|
|
124
|
+
export function signChallenge(privateKeyPem: string, params: SignChallengeParams): string {
|
|
125
|
+
const scopes = params.scopes.join(',');
|
|
126
|
+
const token = params.token ?? '';
|
|
127
|
+
const platform = normalizeMetadata(params.platform);
|
|
128
|
+
const deviceFamily = normalizeMetadata(params.deviceFamily);
|
|
129
|
+
const payload = [
|
|
130
|
+
'v3',
|
|
131
|
+
params.deviceId,
|
|
132
|
+
params.clientId,
|
|
133
|
+
params.clientMode,
|
|
134
|
+
params.role,
|
|
135
|
+
scopes,
|
|
136
|
+
String(params.signedAtMs),
|
|
137
|
+
token,
|
|
138
|
+
params.nonce,
|
|
139
|
+
platform,
|
|
140
|
+
deviceFamily,
|
|
141
|
+
].join('|');
|
|
142
|
+
|
|
143
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
144
|
+
const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key);
|
|
145
|
+
return base64UrlEncode(sig);
|
|
146
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
|
|
2
4
|
|
|
3
5
|
export interface GatewayInjectPayload {
|
|
4
6
|
target: string;
|
|
@@ -40,12 +42,20 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
40
42
|
private requestId = 0;
|
|
41
43
|
private wsUrl!: string;
|
|
42
44
|
private token!: string;
|
|
45
|
+
private identity!: DeviceIdentity;
|
|
46
|
+
private publicKeyBase64Url!: string;
|
|
47
|
+
private challengeNonce = '';
|
|
43
48
|
private pendingRequests = new Map<string, PendingRequest>();
|
|
44
49
|
|
|
45
50
|
async onModuleInit(): Promise<void> {
|
|
46
51
|
this.wsUrl = this.config.get('gateway.wsUrl');
|
|
47
52
|
this.token = this.config.get('gateway.token');
|
|
48
53
|
if (this.wsUrl && this.token) {
|
|
54
|
+
const dbPath = this.config.get('database.url');
|
|
55
|
+
const identityPath = path.join(path.dirname(dbPath), 'device-identity.json');
|
|
56
|
+
this.identity = loadOrCreateIdentity(identityPath);
|
|
57
|
+
this.publicKeyBase64Url = publicKeyToBase64Url(this.identity.publicKeyPem);
|
|
58
|
+
this.logger.info('Device identity loaded', { deviceId: this.identity.deviceId });
|
|
49
59
|
this.connect();
|
|
50
60
|
} else {
|
|
51
61
|
this.logger.warn('Gateway WebSocket not configured — skipping connection (need wsUrl + deviceToken)');
|
|
@@ -99,7 +109,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
99
109
|
|
|
100
110
|
// Server challenge — respond with connect frame
|
|
101
111
|
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
|
102
|
-
this.
|
|
112
|
+
this.challengeNonce = frame.payload?.nonce ?? '';
|
|
113
|
+
this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
|
|
103
114
|
this.sendConnectFrame();
|
|
104
115
|
return;
|
|
105
116
|
}
|
|
@@ -117,7 +128,20 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
117
128
|
if (payload?.type === 'hello-ok') {
|
|
118
129
|
if (!this.connected) {
|
|
119
130
|
this.connected = true;
|
|
120
|
-
|
|
131
|
+
const grantedScopes = payload.auth?.scopes ?? [];
|
|
132
|
+
const serverVersion = payload.server?.version ?? 'unknown';
|
|
133
|
+
this.logger.info('OpenClaw handshake complete', {
|
|
134
|
+
protocol: payload.protocol,
|
|
135
|
+
serverVersion,
|
|
136
|
+
grantedScopes,
|
|
137
|
+
connId: payload.server?.connId,
|
|
138
|
+
});
|
|
139
|
+
if (grantedScopes.length > 0 && !grantedScopes.includes('operator.write')) {
|
|
140
|
+
this.logger.error(
|
|
141
|
+
`Gateway did NOT grant operator.write scope! Granted: [${grantedScopes.join(', ')}]. ` +
|
|
142
|
+
'Message delivery will fail. Rotate the device token with --scope operator.write',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
121
145
|
}
|
|
122
146
|
return;
|
|
123
147
|
}
|
|
@@ -149,33 +173,59 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
149
173
|
private sendConnectFrame(): void {
|
|
150
174
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
|
|
151
175
|
this.connectSent = true;
|
|
152
|
-
|
|
176
|
+
|
|
177
|
+
const signedAt = Date.now();
|
|
178
|
+
const scopes = ['operator.read', 'operator.write'];
|
|
179
|
+
const client = {
|
|
180
|
+
id: 'gateway-client' as const,
|
|
181
|
+
displayName: 'nats-sidecar',
|
|
182
|
+
version: '1.0.0',
|
|
183
|
+
platform: 'linux',
|
|
184
|
+
mode: 'backend' as const,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const signature = signChallenge(this.identity.privateKeyPem, {
|
|
188
|
+
deviceId: this.identity.deviceId,
|
|
189
|
+
clientId: client.id,
|
|
190
|
+
clientMode: client.mode,
|
|
191
|
+
role: 'operator',
|
|
192
|
+
scopes,
|
|
193
|
+
signedAtMs: signedAt,
|
|
194
|
+
token: this.token,
|
|
195
|
+
nonce: this.challengeNonce,
|
|
196
|
+
platform: client.platform,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
this.logger.info('Sending connect frame with device identity', {
|
|
200
|
+
deviceId: this.identity.deviceId,
|
|
201
|
+
});
|
|
153
202
|
|
|
154
203
|
try {
|
|
155
204
|
this.send({
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
205
|
+
type: 'req',
|
|
206
|
+
id: `connect-${++this.requestId}`,
|
|
207
|
+
method: 'connect',
|
|
208
|
+
params: {
|
|
209
|
+
minProtocol: 3,
|
|
210
|
+
maxProtocol: 3,
|
|
211
|
+
client,
|
|
212
|
+
role: 'operator',
|
|
213
|
+
scopes,
|
|
214
|
+
caps: [],
|
|
215
|
+
commands: [],
|
|
216
|
+
permissions: {},
|
|
217
|
+
auth: { token: this.token },
|
|
218
|
+
device: {
|
|
219
|
+
id: this.identity.deviceId,
|
|
220
|
+
publicKey: this.publicKeyBase64Url,
|
|
221
|
+
signature,
|
|
222
|
+
signedAt,
|
|
223
|
+
nonce: this.challengeNonce,
|
|
224
|
+
},
|
|
225
|
+
locale: 'en-US',
|
|
226
|
+
userAgent: 'nats-sidecar/1.0.0',
|
|
168
227
|
},
|
|
169
|
-
|
|
170
|
-
scopes: ['operator.read', 'operator.write'],
|
|
171
|
-
caps: [],
|
|
172
|
-
commands: [],
|
|
173
|
-
permissions: {},
|
|
174
|
-
auth: { token: this.token },
|
|
175
|
-
locale: 'en-US',
|
|
176
|
-
userAgent: 'nats-sidecar/1.0.0',
|
|
177
|
-
},
|
|
178
|
-
});
|
|
228
|
+
});
|
|
179
229
|
} catch (err) {
|
|
180
230
|
this.logger.error('Failed to send connect frame', err);
|
|
181
231
|
this.connectSent = false;
|