Files
stream-control/packages/server/src/agentGateway.ts
jeanotx32 1dd3959f29
Some checks failed
release / build (push) Successful in 29s
release / verify-windows (push) Failing after 1m0s
Feat : Log recorder
2026-08-11 22:34:45 +02:00

176 lines
5.2 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type { IncomingMessage } from 'node:http';
import type { RawData, WebSocket } from 'ws';
import type { AgentToServer, ServerToAgent } from '@stream-control/shared';
import { PROTOCOL_VERSION, detectPlatform, isAgentEvent, safeJsonParse } from '@stream-control/shared';
import { config } from './config.ts';
import { extractBearer, generateToken, hashToken, safeEqual } from './auth.ts';
import { agentsRepo, type AgentRecord } from './db.ts';
import { hub } from './hub.ts';
const HELLO_TIMEOUT_MS = 10_000;
export type AgentAuth =
| { mode: 'known'; record: AgentRecord }
| { mode: 'enroll' };
/**
* Authentifie une tentative de connexion agent à partir du jeton porté par la
* requête d'upgrade. Renvoie null si le jeton est inconnu.
*/
export function authenticateAgent(req: IncomingMessage): AgentAuth | null {
const url = new URL(req.url ?? '/', 'http://localhost');
const token =
extractBearer(req.headers.authorization) ??
url.searchParams.get('token') ??
(typeof req.headers['x-agent-token'] === 'string' ? req.headers['x-agent-token'] : null);
if (!token) return null;
const record = agentsRepo.findByTokenHash(hashToken(token));
if (record) return { mode: 'known', record };
if (config.enrollmentToken && safeEqual(token, config.enrollmentToken)) {
return { mode: 'enroll' };
}
return null;
}
function send(socket: WebSocket, message: ServerToAgent): void {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
}
export function handleAgentConnection(
socket: WebSocket,
auth: AgentAuth,
remoteAddress: string,
): void {
let agentId: string | null = null;
const helloTimer = setTimeout(() => {
if (!agentId) socket.close(4002, 'Message hello absent');
}, HELLO_TIMEOUT_MS);
socket.on('message', (raw: RawData) => {
const message = safeJsonParse<AgentToServer>(raw.toString());
if (!message || typeof message.type !== 'string') {
hub.log(agentId, 'warn', 'Message agent illisible, ignoré');
return;
}
// Tant que l'agent ne s'est pas présenté, seul `hello` est accepté.
if (!agentId && message.type !== 'hello') return;
switch (message.type) {
case 'hello': {
if (agentId) return; // hello dupliqué
clearTimeout(helloTimer);
if (message.protocol !== PROTOCOL_VERSION) {
hub.log(
null,
'warn',
`Agent « ${message.name} » en protocole v${message.protocol}, serveur en v${PROTOCOL_VERSION}`,
);
}
const platform = detectPlatform(message.platform);
let record: AgentRecord;
let issuedToken: string | undefined;
if (auth.mode === 'enroll') {
issuedToken = generateToken();
record = agentsRepo.create({
id: message.agentId?.trim() || randomUUID(),
name: message.name || message.hostname || 'agent',
tokenHash: hashToken(issuedToken),
hostname: message.hostname,
platform,
agentVersion: message.agentVersion,
});
hub.log(
record.id,
'info',
`Nouvel agent enrôlé depuis ${remoteAddress}`,
Date.now(),
'agent.enrolled',
);
} else {
record = auth.record;
agentsRepo.updateIdentity(record.id, {
hostname: message.hostname,
platform,
agentVersion: message.agentVersion,
});
record = agentsRepo.get(record.id) ?? record;
}
agentId = record.id;
hub.attachAgent(record.id, socket);
hub.log(
record.id,
'info',
`Agent connecté (${platform}, ${remoteAddress})`,
Date.now(),
'agent.connected',
);
send(socket, {
type: 'welcome',
agentId: record.id,
token: issuedToken,
obs: record.obs,
statusIntervalMs: config.statusIntervalMs,
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
recording: record.recording,
});
break;
}
case 'status': {
if (!agentId) return;
hub.updateStatus(agentId, { ...message.status, updatedAt: Date.now() });
agentsRepo.touch(agentId);
break;
}
case 'result': {
if (!agentId) return;
hub.markSeen(agentId);
hub.resolveCommand(agentId, message.requestId, message.ok, message.data, message.error);
break;
}
case 'log': {
hub.log(
agentId,
message.level,
message.message,
message.ts || Date.now(),
isAgentEvent(message.event) ? message.event : null,
);
break;
}
case 'pong': {
if (agentId) hub.markSeen(agentId);
break;
}
}
});
socket.on('close', () => {
clearTimeout(helloTimer);
if (agentId) {
hub.detachAgent(agentId, socket);
hub.log(agentId, 'info', 'Agent déconnecté', Date.now(), 'agent.disconnected');
}
});
socket.on('error', (err: Error) => {
hub.log(agentId, 'error', `Erreur socket agent : ${err.message}`);
});
}