@sonde/agent 0.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.
- package/dist/cli/packs.d.ts +23 -0
- package/dist/cli/packs.d.ts.map +1 -0
- package/dist/cli/packs.js +172 -0
- package/dist/cli/packs.js.map +1 -0
- package/dist/cli/packs.test.d.ts +2 -0
- package/dist/cli/packs.test.d.ts.map +1 -0
- package/dist/cli/packs.test.js +171 -0
- package/dist/cli/packs.test.js.map +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/attestation.d.ts +9 -0
- package/dist/runtime/attestation.d.ts.map +1 -0
- package/dist/runtime/attestation.js +32 -0
- package/dist/runtime/attestation.js.map +1 -0
- package/dist/runtime/attestation.test.d.ts +2 -0
- package/dist/runtime/attestation.test.d.ts.map +1 -0
- package/dist/runtime/attestation.test.js +59 -0
- package/dist/runtime/attestation.test.js.map +1 -0
- package/dist/runtime/audit.d.ts +19 -0
- package/dist/runtime/audit.d.ts.map +1 -0
- package/dist/runtime/audit.js +52 -0
- package/dist/runtime/audit.js.map +1 -0
- package/dist/runtime/audit.test.d.ts +2 -0
- package/dist/runtime/audit.test.d.ts.map +1 -0
- package/dist/runtime/audit.test.js +53 -0
- package/dist/runtime/audit.test.js.map +1 -0
- package/dist/runtime/connection.d.ts +55 -0
- package/dist/runtime/connection.d.ts.map +1 -0
- package/dist/runtime/connection.js +325 -0
- package/dist/runtime/connection.js.map +1 -0
- package/dist/runtime/connection.test.d.ts +2 -0
- package/dist/runtime/connection.test.d.ts.map +1 -0
- package/dist/runtime/connection.test.js +221 -0
- package/dist/runtime/connection.test.js.map +1 -0
- package/dist/runtime/executor.d.ts +21 -0
- package/dist/runtime/executor.d.ts.map +1 -0
- package/dist/runtime/executor.js +89 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/executor.test.d.ts +2 -0
- package/dist/runtime/executor.test.d.ts.map +1 -0
- package/dist/runtime/executor.test.js +88 -0
- package/dist/runtime/executor.test.js.map +1 -0
- package/dist/runtime/privilege.d.ts +9 -0
- package/dist/runtime/privilege.d.ts.map +1 -0
- package/dist/runtime/privilege.js +35 -0
- package/dist/runtime/privilege.js.map +1 -0
- package/dist/runtime/privilege.test.d.ts +2 -0
- package/dist/runtime/privilege.test.d.ts.map +1 -0
- package/dist/runtime/privilege.test.js +22 -0
- package/dist/runtime/privilege.test.js.map +1 -0
- package/dist/runtime/scrubber.d.ts +17 -0
- package/dist/runtime/scrubber.d.ts.map +1 -0
- package/dist/runtime/scrubber.js +84 -0
- package/dist/runtime/scrubber.js.map +1 -0
- package/dist/runtime/scrubber.test.d.ts +2 -0
- package/dist/runtime/scrubber.test.d.ts.map +1 -0
- package/dist/runtime/scrubber.test.js +72 -0
- package/dist/runtime/scrubber.test.js.map +1 -0
- package/dist/system/scanner.d.ts +32 -0
- package/dist/system/scanner.d.ts.map +1 -0
- package/dist/system/scanner.js +90 -0
- package/dist/system/scanner.js.map +1 -0
- package/dist/system/scanner.test.d.ts +2 -0
- package/dist/system/scanner.test.d.ts.map +1 -0
- package/dist/system/scanner.test.js +121 -0
- package/dist/system/scanner.test.js.map +1 -0
- package/dist/tui/installer/InstallerApp.d.ts +11 -0
- package/dist/tui/installer/InstallerApp.d.ts.map +1 -0
- package/dist/tui/installer/InstallerApp.js +32 -0
- package/dist/tui/installer/InstallerApp.js.map +1 -0
- package/dist/tui/installer/StepComplete.d.ts +9 -0
- package/dist/tui/installer/StepComplete.d.ts.map +1 -0
- package/dist/tui/installer/StepComplete.js +46 -0
- package/dist/tui/installer/StepComplete.js.map +1 -0
- package/dist/tui/installer/StepHub.d.ts +8 -0
- package/dist/tui/installer/StepHub.d.ts.map +1 -0
- package/dist/tui/installer/StepHub.js +65 -0
- package/dist/tui/installer/StepHub.js.map +1 -0
- package/dist/tui/installer/StepPacks.d.ts +9 -0
- package/dist/tui/installer/StepPacks.d.ts.map +1 -0
- package/dist/tui/installer/StepPacks.js +35 -0
- package/dist/tui/installer/StepPacks.js.map +1 -0
- package/dist/tui/installer/StepPermissions.d.ts +9 -0
- package/dist/tui/installer/StepPermissions.d.ts.map +1 -0
- package/dist/tui/installer/StepPermissions.js +39 -0
- package/dist/tui/installer/StepPermissions.js.map +1 -0
- package/dist/tui/installer/StepScan.d.ts +7 -0
- package/dist/tui/installer/StepScan.d.ts.map +1 -0
- package/dist/tui/installer/StepScan.js +38 -0
- package/dist/tui/installer/StepScan.js.map +1 -0
- package/dist/tui/manager/ActivityLog.d.ts +7 -0
- package/dist/tui/manager/ActivityLog.d.ts.map +1 -0
- package/dist/tui/manager/ActivityLog.js +25 -0
- package/dist/tui/manager/ActivityLog.js.map +1 -0
- package/dist/tui/manager/AuditView.d.ts +7 -0
- package/dist/tui/manager/AuditView.d.ts.map +1 -0
- package/dist/tui/manager/AuditView.js +32 -0
- package/dist/tui/manager/AuditView.js.map +1 -0
- package/dist/tui/manager/ManagerApp.d.ts +20 -0
- package/dist/tui/manager/ManagerApp.d.ts.map +1 -0
- package/dist/tui/manager/ManagerApp.js +79 -0
- package/dist/tui/manager/ManagerApp.js.map +1 -0
- package/dist/tui/manager/PackManager.d.ts +7 -0
- package/dist/tui/manager/PackManager.d.ts.map +1 -0
- package/dist/tui/manager/PackManager.js +22 -0
- package/dist/tui/manager/PackManager.js.map +1 -0
- package/dist/tui/manager/StatusView.d.ts +15 -0
- package/dist/tui/manager/StatusView.d.ts.map +1 -0
- package/dist/tui/manager/StatusView.js +10 -0
- package/dist/tui/manager/StatusView.js.map +1 -0
- package/package.json +45 -0
- package/scripts/install.sh +11 -0
- package/src/cli/packs.test.ts +213 -0
- package/src/cli/packs.ts +214 -0
- package/src/config.ts +62 -0
- package/src/index.ts +218 -0
- package/src/runtime/attestation.test.ts +69 -0
- package/src/runtime/attestation.ts +36 -0
- package/src/runtime/audit.test.ts +64 -0
- package/src/runtime/audit.ts +70 -0
- package/src/runtime/connection.test.ts +303 -0
- package/src/runtime/connection.ts +389 -0
- package/src/runtime/executor.test.ts +112 -0
- package/src/runtime/executor.ts +107 -0
- package/src/runtime/privilege.test.ts +25 -0
- package/src/runtime/privilege.ts +36 -0
- package/src/runtime/scrubber.test.ts +84 -0
- package/src/runtime/scrubber.ts +96 -0
- package/src/system/scanner.test.ts +154 -0
- package/src/system/scanner.ts +133 -0
- package/src/tui/installer/InstallerApp.tsx +86 -0
- package/src/tui/installer/StepComplete.tsx +94 -0
- package/src/tui/installer/StepHub.tsx +111 -0
- package/src/tui/installer/StepPacks.tsx +73 -0
- package/src/tui/installer/StepPermissions.tsx +104 -0
- package/src/tui/installer/StepScan.tsx +82 -0
- package/src/tui/manager/ActivityLog.tsx +57 -0
- package/src/tui/manager/AuditView.tsx +73 -0
- package/src/tui/manager/ManagerApp.tsx +157 -0
- package/src/tui/manager/PackManager.tsx +71 -0
- package/src/tui/manager/StatusView.tsx +103 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
HEARTBEAT_INTERVAL_MS,
|
|
4
|
+
type MessageEnvelope,
|
|
5
|
+
MessageEnvelope as MessageEnvelopeSchema,
|
|
6
|
+
type ProbeRequest,
|
|
7
|
+
ProbeRequest as ProbeRequestSchema,
|
|
8
|
+
signPayload,
|
|
9
|
+
verifyPayload,
|
|
10
|
+
} from '@sonde/shared';
|
|
11
|
+
import WebSocket from 'ws';
|
|
12
|
+
import type { AgentConfig } from '../config.js';
|
|
13
|
+
import { saveCerts } from '../config.js';
|
|
14
|
+
import { generateAttestation } from './attestation.js';
|
|
15
|
+
import { AgentAuditLog } from './audit.js';
|
|
16
|
+
import type { ProbeExecutor } from './executor.js';
|
|
17
|
+
|
|
18
|
+
export interface ConnectionEvents {
|
|
19
|
+
onConnected?: (agentId: string) => void;
|
|
20
|
+
onDisconnected?: () => void;
|
|
21
|
+
onError?: (error: Error) => void;
|
|
22
|
+
onRegistered?: (agentId: string) => void;
|
|
23
|
+
onProbeCompleted?: (probe: string, status: string, durationMs: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Minimum/maximum reconnect delays */
|
|
27
|
+
const MIN_RECONNECT_MS = 1_000;
|
|
28
|
+
const MAX_RECONNECT_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
const ENROLL_TIMEOUT_MS = 10_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One-shot enrollment: connect to hub, register, get agentId, disconnect.
|
|
34
|
+
* If enrollmentToken is set in config, includes it in registration for cert-based enrollment.
|
|
35
|
+
* Returns { agentId, certIssued } — certIssued is true if certs were saved.
|
|
36
|
+
*/
|
|
37
|
+
export function enrollWithHub(
|
|
38
|
+
config: AgentConfig,
|
|
39
|
+
executor: ProbeExecutor,
|
|
40
|
+
): Promise<{ agentId: string; certIssued: boolean }> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const wsUrl = `${config.hubUrl.replace(/^http/, 'ws')}/ws/agent`;
|
|
43
|
+
|
|
44
|
+
const ws = new WebSocket(wsUrl, {
|
|
45
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const timeout = setTimeout(() => {
|
|
49
|
+
ws.close();
|
|
50
|
+
reject(new Error('Enrollment timed out waiting for hub acknowledgement'));
|
|
51
|
+
}, ENROLL_TIMEOUT_MS);
|
|
52
|
+
|
|
53
|
+
ws.on('open', () => {
|
|
54
|
+
const payload: Record<string, unknown> = {
|
|
55
|
+
name: config.agentName,
|
|
56
|
+
os: `${process.platform} ${process.arch}`,
|
|
57
|
+
agentVersion: '0.1.0',
|
|
58
|
+
packs: executor.getLoadedPacks(),
|
|
59
|
+
attestation: generateAttestation(config, executor),
|
|
60
|
+
};
|
|
61
|
+
if (config.enrollmentToken) {
|
|
62
|
+
payload.enrollmentToken = config.enrollmentToken;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ws.send(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
id: crypto.randomUUID(),
|
|
68
|
+
type: 'agent.register',
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
signature: '',
|
|
71
|
+
payload,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
ws.on('message', (data) => {
|
|
77
|
+
let envelope: MessageEnvelope;
|
|
78
|
+
try {
|
|
79
|
+
envelope = MessageEnvelopeSchema.parse(JSON.parse(data.toString()));
|
|
80
|
+
} catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (envelope.type === 'hub.ack') {
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
const ackPayload = envelope.payload as {
|
|
87
|
+
agentId?: string;
|
|
88
|
+
error?: string;
|
|
89
|
+
certPem?: string;
|
|
90
|
+
keyPem?: string;
|
|
91
|
+
caCertPem?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
ws.close();
|
|
95
|
+
|
|
96
|
+
if (ackPayload.error) {
|
|
97
|
+
reject(new Error(ackPayload.error));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const agentId = ackPayload.agentId;
|
|
102
|
+
if (!agentId) {
|
|
103
|
+
reject(new Error('Hub ack did not contain agentId'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If hub issued certs, save them
|
|
108
|
+
let certIssued = false;
|
|
109
|
+
if (ackPayload.certPem && ackPayload.keyPem && ackPayload.caCertPem) {
|
|
110
|
+
saveCerts(config, ackPayload.certPem, ackPayload.keyPem, ackPayload.caCertPem);
|
|
111
|
+
certIssued = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
resolve({ agentId, certIssued });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
ws.on('error', (err) => {
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
reject(err);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Build WebSocket options, including TLS client cert if available. */
|
|
126
|
+
function buildWsOptions(config: AgentConfig): WebSocket.ClientOptions {
|
|
127
|
+
const options: WebSocket.ClientOptions = {
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (config.certPath && config.keyPath && config.caCertPath) {
|
|
134
|
+
try {
|
|
135
|
+
options.cert = fs.readFileSync(config.certPath, 'utf-8');
|
|
136
|
+
options.key = fs.readFileSync(config.keyPath, 'utf-8');
|
|
137
|
+
options.ca = [fs.readFileSync(config.caCertPath, 'utf-8')];
|
|
138
|
+
options.rejectUnauthorized = false; // Hub uses self-signed CA cert
|
|
139
|
+
} catch {
|
|
140
|
+
// Cert files missing or unreadable — fall back to API key only
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return options;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class AgentConnection {
|
|
148
|
+
private ws: WebSocket | null = null;
|
|
149
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
150
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
151
|
+
private reconnectAttempts = 0;
|
|
152
|
+
private agentId: string | undefined;
|
|
153
|
+
private running = false;
|
|
154
|
+
private privateKeyPem: string | undefined;
|
|
155
|
+
private caCertPem: string | undefined;
|
|
156
|
+
private auditLog = new AgentAuditLog();
|
|
157
|
+
|
|
158
|
+
constructor(
|
|
159
|
+
private config: AgentConfig,
|
|
160
|
+
private executor: ProbeExecutor,
|
|
161
|
+
private events: ConnectionEvents = {},
|
|
162
|
+
) {
|
|
163
|
+
// Load private key for signing outbound messages
|
|
164
|
+
if (config.keyPath) {
|
|
165
|
+
try {
|
|
166
|
+
this.privateKeyPem = fs.readFileSync(config.keyPath, 'utf-8');
|
|
167
|
+
} catch {
|
|
168
|
+
// Key not available — messages will be sent unsigned
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Load CA cert for verifying hub messages
|
|
172
|
+
if (config.caCertPath) {
|
|
173
|
+
try {
|
|
174
|
+
this.caCertPem = fs.readFileSync(config.caCertPath, 'utf-8');
|
|
175
|
+
} catch {
|
|
176
|
+
// CA cert not available — skip verification
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Start the connection (connect + auto-reconnect loop) */
|
|
182
|
+
start(): void {
|
|
183
|
+
this.running = true;
|
|
184
|
+
this.connect();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Stop the connection cleanly */
|
|
188
|
+
stop(): void {
|
|
189
|
+
this.running = false;
|
|
190
|
+
this.clearTimers();
|
|
191
|
+
if (this.ws) {
|
|
192
|
+
this.ws.close(1000, 'Agent shutting down');
|
|
193
|
+
this.ws = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether we're currently connected */
|
|
198
|
+
isConnected(): boolean {
|
|
199
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Get the assigned agent ID (set after registration) */
|
|
203
|
+
getAgentId(): string | undefined {
|
|
204
|
+
return this.agentId;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private connect(): void {
|
|
208
|
+
const wsUrl = `${this.config.hubUrl.replace(/^http/, 'ws')}/ws/agent`;
|
|
209
|
+
const options = buildWsOptions(this.config);
|
|
210
|
+
|
|
211
|
+
this.ws = new WebSocket(wsUrl, options);
|
|
212
|
+
|
|
213
|
+
this.ws.on('open', () => {
|
|
214
|
+
this.reconnectAttempts = 0;
|
|
215
|
+
this.sendRegister();
|
|
216
|
+
this.startHeartbeat();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.ws.on('message', (data) => {
|
|
220
|
+
this.handleMessage(data.toString());
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
this.ws.on('close', () => {
|
|
224
|
+
this.clearTimers();
|
|
225
|
+
this.events.onDisconnected?.();
|
|
226
|
+
if (this.running) {
|
|
227
|
+
this.scheduleReconnect();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
this.ws.on('error', (err) => {
|
|
232
|
+
this.events.onError?.(err);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private handleMessage(raw: string): void {
|
|
237
|
+
let envelope: MessageEnvelope;
|
|
238
|
+
try {
|
|
239
|
+
envelope = MessageEnvelopeSchema.parse(JSON.parse(raw));
|
|
240
|
+
} catch {
|
|
241
|
+
return; // Invalid message, ignore
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Verify hub signature if we have a CA cert and the message is signed
|
|
245
|
+
if (this.caCertPem && envelope.signature !== '') {
|
|
246
|
+
const valid = verifyPayload(envelope.payload, envelope.signature, this.caCertPem);
|
|
247
|
+
if (!valid) {
|
|
248
|
+
this.events.onError?.(new Error(`Signature verification failed for ${envelope.type}`));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
switch (envelope.type) {
|
|
254
|
+
case 'hub.ack':
|
|
255
|
+
this.handleAck(envelope);
|
|
256
|
+
break;
|
|
257
|
+
case 'probe.request':
|
|
258
|
+
this.handleProbeRequest(envelope);
|
|
259
|
+
break;
|
|
260
|
+
default:
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private handleAck(envelope: MessageEnvelope): void {
|
|
266
|
+
const payload = envelope.payload as { agentId?: string };
|
|
267
|
+
if (payload.agentId) {
|
|
268
|
+
this.agentId = payload.agentId;
|
|
269
|
+
this.events.onRegistered?.(this.agentId);
|
|
270
|
+
}
|
|
271
|
+
this.events.onConnected?.(this.agentId ?? '');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async handleProbeRequest(envelope: MessageEnvelope): Promise<void> {
|
|
275
|
+
if (!this.agentId) return;
|
|
276
|
+
|
|
277
|
+
let request: ProbeRequest;
|
|
278
|
+
try {
|
|
279
|
+
request = ProbeRequestSchema.parse(envelope.payload);
|
|
280
|
+
} catch {
|
|
281
|
+
this.sendError(envelope.id, 'Invalid probe request payload');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const response = await this.executor.execute(request);
|
|
286
|
+
|
|
287
|
+
this.auditLog.log(request.probe, response.status, response.durationMs);
|
|
288
|
+
this.events.onProbeCompleted?.(request.probe, response.status, response.durationMs);
|
|
289
|
+
|
|
290
|
+
this.send({
|
|
291
|
+
id: crypto.randomUUID(),
|
|
292
|
+
type: response.status === 'success' ? 'probe.response' : 'probe.error',
|
|
293
|
+
timestamp: new Date().toISOString(),
|
|
294
|
+
agentId: this.agentId,
|
|
295
|
+
signature: '',
|
|
296
|
+
payload: response,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Get the agent-side audit log (for testing/inspection) */
|
|
301
|
+
getAuditLog(): AgentAuditLog {
|
|
302
|
+
return this.auditLog;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private sendRegister(): void {
|
|
306
|
+
this.send({
|
|
307
|
+
id: crypto.randomUUID(),
|
|
308
|
+
type: 'agent.register',
|
|
309
|
+
timestamp: new Date().toISOString(),
|
|
310
|
+
signature: '',
|
|
311
|
+
payload: {
|
|
312
|
+
name: this.config.agentName,
|
|
313
|
+
os: `${process.platform} ${process.arch}`,
|
|
314
|
+
agentVersion: '0.1.0',
|
|
315
|
+
packs: this.executor.getLoadedPacks(),
|
|
316
|
+
attestation: generateAttestation(this.config, this.executor),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private startHeartbeat(): void {
|
|
322
|
+
this.heartbeatTimer = setInterval(() => {
|
|
323
|
+
if (!this.agentId) return;
|
|
324
|
+
this.send({
|
|
325
|
+
id: crypto.randomUUID(),
|
|
326
|
+
type: 'agent.heartbeat',
|
|
327
|
+
timestamp: new Date().toISOString(),
|
|
328
|
+
agentId: this.agentId,
|
|
329
|
+
signature: '',
|
|
330
|
+
payload: {},
|
|
331
|
+
});
|
|
332
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private sendError(requestId: string, message: string): void {
|
|
336
|
+
if (!this.agentId) return;
|
|
337
|
+
this.send({
|
|
338
|
+
id: crypto.randomUUID(),
|
|
339
|
+
type: 'probe.error',
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
agentId: this.agentId,
|
|
342
|
+
signature: '',
|
|
343
|
+
payload: {
|
|
344
|
+
probe: 'unknown',
|
|
345
|
+
status: 'error',
|
|
346
|
+
data: { error: message },
|
|
347
|
+
durationMs: 0,
|
|
348
|
+
metadata: {
|
|
349
|
+
agentVersion: '0.1.0',
|
|
350
|
+
packName: 'unknown',
|
|
351
|
+
packVersion: '0.0.0',
|
|
352
|
+
capabilityLevel: 'observe',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private send(envelope: MessageEnvelope): void {
|
|
359
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
360
|
+
// Sign the payload if we have a private key
|
|
361
|
+
if (this.privateKeyPem && envelope.signature === '') {
|
|
362
|
+
envelope.signature = signPayload(envelope.payload, this.privateKeyPem);
|
|
363
|
+
}
|
|
364
|
+
this.ws.send(JSON.stringify(envelope));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private scheduleReconnect(): void {
|
|
369
|
+
const delay = Math.min(MIN_RECONNECT_MS * 2 ** this.reconnectAttempts, MAX_RECONNECT_MS);
|
|
370
|
+
this.reconnectAttempts++;
|
|
371
|
+
|
|
372
|
+
this.reconnectTimer = setTimeout(() => {
|
|
373
|
+
if (this.running) {
|
|
374
|
+
this.connect();
|
|
375
|
+
}
|
|
376
|
+
}, delay);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private clearTimers(): void {
|
|
380
|
+
if (this.heartbeatTimer) {
|
|
381
|
+
clearInterval(this.heartbeatTimer);
|
|
382
|
+
this.heartbeatTimer = null;
|
|
383
|
+
}
|
|
384
|
+
if (this.reconnectTimer) {
|
|
385
|
+
clearTimeout(this.reconnectTimer);
|
|
386
|
+
this.reconnectTimer = null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ExecFn, Pack } from '@sonde/packs';
|
|
2
|
+
import type { ProbeRequest } from '@sonde/shared';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ProbeExecutor } from './executor.js';
|
|
5
|
+
|
|
6
|
+
function createMockPack(overrides?: Partial<Pack>): Pack {
|
|
7
|
+
return {
|
|
8
|
+
manifest: {
|
|
9
|
+
name: 'test',
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
description: 'Test pack',
|
|
12
|
+
requires: { groups: [], files: [], commands: [] },
|
|
13
|
+
probes: [{ name: 'echo', description: 'Echo test', capability: 'observe', timeout: 5000 }],
|
|
14
|
+
},
|
|
15
|
+
handlers: {
|
|
16
|
+
'test.echo': vi.fn().mockResolvedValue({ message: 'hello' }),
|
|
17
|
+
},
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeRequest(probe: string, params?: Record<string, unknown>): ProbeRequest {
|
|
23
|
+
return {
|
|
24
|
+
probe,
|
|
25
|
+
params,
|
|
26
|
+
timeout: 30_000,
|
|
27
|
+
requestedBy: 'test',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('ProbeExecutor', () => {
|
|
32
|
+
it('executes a probe and returns success response', async () => {
|
|
33
|
+
const pack = createMockPack();
|
|
34
|
+
const packs = new Map([['test', pack]]);
|
|
35
|
+
const executor = new ProbeExecutor(packs);
|
|
36
|
+
|
|
37
|
+
const result = await executor.execute(makeRequest('test.echo'));
|
|
38
|
+
|
|
39
|
+
expect(result.status).toBe('success');
|
|
40
|
+
expect(result.probe).toBe('test.echo');
|
|
41
|
+
expect(result.data).toEqual({ message: 'hello' });
|
|
42
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
43
|
+
expect(result.metadata.packName).toBe('test');
|
|
44
|
+
expect(result.metadata.packVersion).toBe('1.0.0');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('passes params and exec to handler', async () => {
|
|
48
|
+
const handler = vi.fn().mockResolvedValue({ ok: true });
|
|
49
|
+
const mockExec: ExecFn = vi.fn();
|
|
50
|
+
const pack = createMockPack({ handlers: { 'test.echo': handler } });
|
|
51
|
+
const packs = new Map([['test', pack]]);
|
|
52
|
+
const executor = new ProbeExecutor(packs, mockExec);
|
|
53
|
+
|
|
54
|
+
await executor.execute(makeRequest('test.echo', { verbose: true }));
|
|
55
|
+
|
|
56
|
+
expect(handler).toHaveBeenCalledWith({ verbose: true }, mockExec);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns error for unknown pack', async () => {
|
|
60
|
+
const executor = new ProbeExecutor(new Map());
|
|
61
|
+
|
|
62
|
+
const result = await executor.execute(makeRequest('missing.probe'));
|
|
63
|
+
|
|
64
|
+
expect(result.status).toBe('error');
|
|
65
|
+
expect(result.data).toEqual({ error: "Pack 'missing' not loaded" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns error for unknown probe within pack', async () => {
|
|
69
|
+
const pack = createMockPack();
|
|
70
|
+
const packs = new Map([['test', pack]]);
|
|
71
|
+
const executor = new ProbeExecutor(packs);
|
|
72
|
+
|
|
73
|
+
const result = await executor.execute(makeRequest('test.nonexistent'));
|
|
74
|
+
|
|
75
|
+
expect(result.status).toBe('error');
|
|
76
|
+
expect(result.data).toEqual({ error: 'Unknown probe: test.nonexistent' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns error when handler throws', async () => {
|
|
80
|
+
const pack = createMockPack({
|
|
81
|
+
handlers: {
|
|
82
|
+
'test.echo': vi.fn().mockRejectedValue(new Error('Command failed')),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const packs = new Map([['test', pack]]);
|
|
86
|
+
const executor = new ProbeExecutor(packs);
|
|
87
|
+
|
|
88
|
+
const result = await executor.execute(makeRequest('test.echo'));
|
|
89
|
+
|
|
90
|
+
expect(result.status).toBe('error');
|
|
91
|
+
expect(result.data).toEqual({ error: 'Command failed' });
|
|
92
|
+
expect(result.metadata.packName).toBe('test');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns error for invalid probe name', async () => {
|
|
96
|
+
const executor = new ProbeExecutor(new Map());
|
|
97
|
+
|
|
98
|
+
const result = await executor.execute(makeRequest(''));
|
|
99
|
+
|
|
100
|
+
expect(result.status).toBe('error');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('reports loaded packs', () => {
|
|
104
|
+
const pack = createMockPack();
|
|
105
|
+
const packs = new Map([['test', pack]]);
|
|
106
|
+
const executor = new ProbeExecutor(packs);
|
|
107
|
+
|
|
108
|
+
const loaded = executor.getLoadedPacks();
|
|
109
|
+
|
|
110
|
+
expect(loaded).toEqual([{ name: 'test', version: '1.0.0', status: 'active' }]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { type ExecFn, type Pack, packRegistry } from '@sonde/packs';
|
|
4
|
+
import type { ProbeRequest, ProbeResponse } from '@sonde/shared';
|
|
5
|
+
import { type ScrubPattern, buildPatterns, scrubData } from './scrubber.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
const AGENT_VERSION = '0.1.0';
|
|
10
|
+
|
|
11
|
+
/** Default exec function that shells out to real commands */
|
|
12
|
+
async function defaultExec(command: string, args: string[]): Promise<string> {
|
|
13
|
+
const { stdout } = await execFileAsync(command, args, {
|
|
14
|
+
timeout: 30_000,
|
|
15
|
+
maxBuffer: 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
return stdout;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ProbeExecutor {
|
|
21
|
+
private packs: ReadonlyMap<string, Pack>;
|
|
22
|
+
private exec: ExecFn;
|
|
23
|
+
private scrubPatterns: ScrubPattern[];
|
|
24
|
+
|
|
25
|
+
constructor(packs?: ReadonlyMap<string, Pack>, exec?: ExecFn, scrubPatterns?: ScrubPattern[]) {
|
|
26
|
+
this.packs = packs ?? packRegistry;
|
|
27
|
+
this.exec = exec ?? defaultExec;
|
|
28
|
+
this.scrubPatterns = scrubPatterns ?? buildPatterns();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get list of loaded packs with status info */
|
|
32
|
+
getLoadedPacks(): Array<{ name: string; version: string; status: string }> {
|
|
33
|
+
return [...this.packs.values()].map((pack) => ({
|
|
34
|
+
name: pack.manifest.name,
|
|
35
|
+
version: pack.manifest.version,
|
|
36
|
+
status: 'active',
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get a pack by name */
|
|
41
|
+
getPackByName(name: string): Pack | undefined {
|
|
42
|
+
return this.packs.get(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Execute a probe request and return a probe response */
|
|
46
|
+
async execute(request: ProbeRequest): Promise<ProbeResponse> {
|
|
47
|
+
const start = Date.now();
|
|
48
|
+
|
|
49
|
+
// Find the handler by full probe name (e.g. "system.disk.usage")
|
|
50
|
+
const probeName = request.probe;
|
|
51
|
+
const packName = probeName.split('.')[0];
|
|
52
|
+
|
|
53
|
+
if (!packName) {
|
|
54
|
+
return this.errorResponse(probeName, start, `Invalid probe name: ${probeName}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pack = this.packs.get(packName);
|
|
58
|
+
if (!pack) {
|
|
59
|
+
return this.errorResponse(probeName, start, `Pack '${packName}' not loaded`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handler = pack.handlers[probeName];
|
|
63
|
+
if (!handler) {
|
|
64
|
+
return this.errorResponse(probeName, start, `Unknown probe: ${probeName}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const rawData = await handler(request.params, this.exec);
|
|
69
|
+
const data = scrubData(rawData, this.scrubPatterns);
|
|
70
|
+
return {
|
|
71
|
+
probe: probeName,
|
|
72
|
+
status: 'success',
|
|
73
|
+
data,
|
|
74
|
+
durationMs: Date.now() - start,
|
|
75
|
+
metadata: {
|
|
76
|
+
agentVersion: AGENT_VERSION,
|
|
77
|
+
packName: pack.manifest.name,
|
|
78
|
+
packVersion: pack.manifest.version,
|
|
79
|
+
capabilityLevel: 'observe',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
84
|
+
return this.errorResponse(probeName, start, message, pack);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private errorResponse(
|
|
89
|
+
probe: string,
|
|
90
|
+
startMs: number,
|
|
91
|
+
message: string,
|
|
92
|
+
pack?: Pack,
|
|
93
|
+
): ProbeResponse {
|
|
94
|
+
return {
|
|
95
|
+
probe,
|
|
96
|
+
status: 'error',
|
|
97
|
+
data: { error: message },
|
|
98
|
+
durationMs: Date.now() - startMs,
|
|
99
|
+
metadata: {
|
|
100
|
+
agentVersion: AGENT_VERSION,
|
|
101
|
+
packName: pack?.manifest.name ?? 'unknown',
|
|
102
|
+
packVersion: pack?.manifest.version ?? '0.0.0',
|
|
103
|
+
capabilityLevel: 'observe',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { checkNotRoot, sondeUserExists, suggestGroupAdd } from './privilege.js';
|
|
3
|
+
|
|
4
|
+
describe('privilege', () => {
|
|
5
|
+
it('checkNotRoot does not throw for non-root', () => {
|
|
6
|
+
// Tests run as non-root, so this should not exit
|
|
7
|
+
expect(() => checkNotRoot()).not.toThrow();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('sondeUserExists returns a boolean', () => {
|
|
11
|
+
const result = sondeUserExists();
|
|
12
|
+
expect(typeof result).toBe('boolean');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('suggestGroupAdd returns correct command', () => {
|
|
16
|
+
expect(suggestGroupAdd('docker')).toBe('sudo usermod -aG docker sonde');
|
|
17
|
+
expect(suggestGroupAdd('systemd-journal')).toBe('sudo usermod -aG systemd-journal sonde');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('suggestGroupAdd with different groups', () => {
|
|
21
|
+
const cmd = suggestGroupAdd('adm');
|
|
22
|
+
expect(cmd).toContain('adm');
|
|
23
|
+
expect(cmd).toContain('sonde');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Exit with error if running as root (uid 0) */
|
|
4
|
+
export function checkNotRoot(): void {
|
|
5
|
+
if (process.getuid?.() === 0) {
|
|
6
|
+
console.error('Error: sonde agent must not run as root.');
|
|
7
|
+
console.error('Run as the dedicated "sonde" user or a non-root account.');
|
|
8
|
+
console.error('See: packages/agent/scripts/install.sh');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Check if the 'sonde' system user exists */
|
|
14
|
+
export function sondeUserExists(): boolean {
|
|
15
|
+
try {
|
|
16
|
+
execFileSync('id', ['-u', 'sonde'], { stdio: 'ignore' });
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Return group list for the sonde user */
|
|
24
|
+
export function getSondeGroups(): string[] {
|
|
25
|
+
try {
|
|
26
|
+
const output = execFileSync('id', ['-Gn', 'sonde'], { encoding: 'utf-8' }).trim();
|
|
27
|
+
return output ? output.split(/\s+/) : [];
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Return the command to add the sonde user to a group */
|
|
34
|
+
export function suggestGroupAdd(group: string): string {
|
|
35
|
+
return `sudo usermod -aG ${group} sonde`;
|
|
36
|
+
}
|