FP
This commit is contained in:
154
packages/server/src/agentGateway.ts
Normal file
154
packages/server/src/agentGateway.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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, 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}`);
|
||||
} 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})`);
|
||||
|
||||
send(socket, {
|
||||
type: 'welcome',
|
||||
agentId: record.id,
|
||||
token: issuedToken,
|
||||
obs: record.obs,
|
||||
statusIntervalMs: config.statusIntervalMs,
|
||||
autoConnectObs: record.autoConnectObs,
|
||||
});
|
||||
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());
|
||||
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é');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err: Error) => {
|
||||
hub.log(agentId, 'error', `Erreur socket agent : ${err.message}`);
|
||||
});
|
||||
}
|
||||
209
packages/server/src/api.ts
Normal file
209
packages/server/src/api.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Router } from 'express';
|
||||
import type { ObsSettings } from '@stream-control/shared';
|
||||
import { AGENT_ACTIONS, DEFAULT_OBS_SETTINGS, isAgentAction } from '@stream-control/shared';
|
||||
import { config } from './config.ts';
|
||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
||||
import { agentsRepo, logsRepo } from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
|
||||
export const api: Router = Router();
|
||||
|
||||
// --- Authentification -------------------------------------------------------
|
||||
|
||||
api.post('/login', (req, res) => {
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
||||
if (!safeEqual(password, config.adminPassword)) {
|
||||
hub.log(null, 'warn', `Échec de connexion au dashboard depuis ${req.ip}`);
|
||||
res.status(401).json({ error: 'Mot de passe invalide' });
|
||||
return;
|
||||
}
|
||||
res.json(issueSession());
|
||||
});
|
||||
|
||||
api.use(requireSession);
|
||||
|
||||
api.get('/session', (_req, res) => {
|
||||
res.json({ ok: true, actions: AGENT_ACTIONS });
|
||||
});
|
||||
|
||||
// --- Agents -----------------------------------------------------------------
|
||||
|
||||
api.get('/agents', (_req, res) => {
|
||||
res.json({ agents: hub.views() });
|
||||
});
|
||||
|
||||
api.get('/agents/:id', (req, res) => {
|
||||
const record = agentsRepo.get(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Agent introuvable' });
|
||||
return;
|
||||
}
|
||||
res.json({ agent: hub.view(record) });
|
||||
});
|
||||
|
||||
/** Provisionnement manuel : renvoie le jeton en clair une seule fois. */
|
||||
api.post('/agents', (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Le nom est obligatoire' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const record = agentsRepo.create({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
tokenHash: hashToken(token),
|
||||
obs: parseObs(req.body?.obs),
|
||||
autoConnectObs: req.body?.autoConnectObs !== false,
|
||||
notes: typeof req.body?.notes === 'string' ? req.body.notes : null,
|
||||
});
|
||||
|
||||
hub.log(record.id, 'info', `Agent « ${name} » créé depuis le dashboard`);
|
||||
hub.publishAgent(record.id);
|
||||
res.status(201).json({ agent: hub.view(record), token });
|
||||
});
|
||||
|
||||
api.patch('/agents/:id', (req, res) => {
|
||||
const record = agentsRepo.get(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Agent introuvable' });
|
||||
return;
|
||||
}
|
||||
|
||||
const obs = parseObs(req.body?.obs, record.obs);
|
||||
// Le dashboard reçoit le mot de passe masqué : on ne l'écrase pas s'il revient tel quel.
|
||||
if (obs.password === '********') obs.password = record.obs.password;
|
||||
|
||||
agentsRepo.updateSettings(record.id, {
|
||||
name: typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : record.name,
|
||||
obs,
|
||||
autoConnectObs:
|
||||
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
|
||||
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
|
||||
});
|
||||
|
||||
const updated = agentsRepo.get(record.id);
|
||||
if (updated) {
|
||||
hub.pushConfig(updated);
|
||||
hub.publishAgent(updated.id);
|
||||
res.json({ agent: hub.view(updated) });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Mise à jour impossible' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Régénère le jeton d'un agent : l'ancienne session est coupée. */
|
||||
api.post('/agents/:id/token', (req, res) => {
|
||||
const record = agentsRepo.get(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Agent introuvable' });
|
||||
return;
|
||||
}
|
||||
const token = generateToken();
|
||||
agentsRepo.rotateToken(record.id, hashToken(token));
|
||||
hub.disconnectAgent(record.id, 'Jeton régénéré');
|
||||
hub.log(record.id, 'warn', 'Jeton régénéré : reconfigure l\'agent');
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
api.delete('/agents/:id', (req, res) => {
|
||||
const record = agentsRepo.get(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Agent introuvable' });
|
||||
return;
|
||||
}
|
||||
hub.disconnectAgent(record.id, 'Agent supprimé');
|
||||
agentsRepo.remove(record.id);
|
||||
hub.publishRemoval(record.id);
|
||||
hub.log(null, 'warn', `Agent « ${record.name} » supprimé`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Commandes --------------------------------------------------------------
|
||||
|
||||
api.post('/agents/:id/command', async (req, res) => {
|
||||
const action = req.body?.action;
|
||||
if (!isAgentAction(action)) {
|
||||
res.status(400).json({ error: `Action inconnue : ${String(action)}` });
|
||||
return;
|
||||
}
|
||||
const record = agentsRepo.get(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Agent introuvable' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await hub.sendCommand(record.id, action, req.body?.params);
|
||||
hub.log(record.id, 'info', `Commande « ${action} » exécutée`);
|
||||
res.json({ ok: true, data });
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : String(err);
|
||||
hub.log(record.id, 'error', `Commande « ${action} » en échec : ${messageText}`);
|
||||
res.status(502).json({ ok: false, error: messageText });
|
||||
}
|
||||
});
|
||||
|
||||
/** Commande groupée : même action sur plusieurs agents, résultats par agent. */
|
||||
api.post('/commands/bulk', async (req, res) => {
|
||||
const action = req.body?.action;
|
||||
if (!isAgentAction(action)) {
|
||||
res.status(400).json({ error: `Action inconnue : ${String(action)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const requested: string[] = Array.isArray(req.body?.agentIds)
|
||||
? req.body.agentIds.filter((id: unknown): id is string => typeof id === 'string')
|
||||
: agentsRepo.list().map((record) => record.id);
|
||||
|
||||
const results = await Promise.all(
|
||||
requested.map(async (agentId) => {
|
||||
try {
|
||||
const data = await hub.sendCommand(agentId, action, req.body?.params);
|
||||
return { agentId, ok: true as const, data };
|
||||
} catch (err) {
|
||||
return {
|
||||
agentId,
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const failed = results.filter((result) => !result.ok).length;
|
||||
hub.log(
|
||||
null,
|
||||
failed ? 'warn' : 'info',
|
||||
`Commande groupée « ${action} » : ${results.length - failed}/${results.length} OK`,
|
||||
);
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
// --- Divers -----------------------------------------------------------------
|
||||
|
||||
api.get('/logs', (req, res) => {
|
||||
const limit = Math.min(Number.parseInt(String(req.query.limit ?? '200'), 10) || 200, 1000);
|
||||
res.json({ logs: logsRepo.recent(limit) });
|
||||
});
|
||||
|
||||
/** Infos nécessaires pour configurer un nouvel agent. */
|
||||
api.get('/enrollment', (req, res) => {
|
||||
res.json({
|
||||
enabled: Boolean(config.enrollmentToken),
|
||||
token: config.enrollmentToken || null,
|
||||
serverUrl: `${req.protocol === 'https' ? 'wss' : 'ws'}://${req.get('host')}/ws/agent`,
|
||||
});
|
||||
});
|
||||
|
||||
function parseObs(raw: unknown, fallback: ObsSettings = DEFAULT_OBS_SETTINGS): ObsSettings {
|
||||
const input = (raw ?? {}) as Partial<ObsSettings>;
|
||||
const port = Number(input.port);
|
||||
return {
|
||||
host: typeof input.host === 'string' && input.host.trim() ? input.host.trim() : fallback.host,
|
||||
port: Number.isFinite(port) && port > 0 && port < 65536 ? port : fallback.port,
|
||||
password: typeof input.password === 'string' ? input.password : fallback.password,
|
||||
};
|
||||
}
|
||||
63
packages/server/src/auth.ts
Normal file
63
packages/server/src/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createHmac, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { config } from './config.ts';
|
||||
|
||||
/** Comparaison à temps constant, tolérante aux longueurs différentes. */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a, 'utf8');
|
||||
const bufB = Buffer.from(b, 'utf8');
|
||||
if (bufA.length !== bufB.length) {
|
||||
// On compare quand même pour ne pas court-circuiter sur la longueur.
|
||||
timingSafeEqual(bufA, bufA);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function generateToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
// --- Sessions du dashboard : jeton `<expiration>.<hmac>` sans stockage serveur ---
|
||||
|
||||
function sign(payload: string): string {
|
||||
return createHmac('sha256', config.sessionSecret).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
export function issueSession(): { token: string; expiresAt: number } {
|
||||
const expiresAt = Date.now() + config.sessionTtlMs;
|
||||
const payload = String(expiresAt);
|
||||
return { token: `${payload}.${sign(payload)}`, expiresAt };
|
||||
}
|
||||
|
||||
export function verifySession(token: string | undefined | null): boolean {
|
||||
if (!token) return false;
|
||||
const dot = token.lastIndexOf('.');
|
||||
if (dot <= 0) return false;
|
||||
const payload = token.slice(0, dot);
|
||||
const signature = token.slice(dot + 1);
|
||||
if (!safeEqual(signature, sign(payload))) return false;
|
||||
const expiresAt = Number.parseInt(payload, 10);
|
||||
return Number.isFinite(expiresAt) && expiresAt > Date.now();
|
||||
}
|
||||
|
||||
export function extractBearer(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const [scheme, value] = header.split(' ');
|
||||
if (!value || scheme?.toLowerCase() !== 'bearer') return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** Middleware Express protégeant les routes /api (hors login). */
|
||||
export function requireSession(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = extractBearer(req.headers.authorization) ?? (req.query.token as string | undefined);
|
||||
if (!verifySession(token)) {
|
||||
res.status(401).json({ error: 'Non authentifié' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
55
packages/server/src/config.ts
Normal file
55
packages/server/src/config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, '../../..');
|
||||
|
||||
dotenv.config({ path: path.join(repoRoot, '.env'), quiet: true });
|
||||
|
||||
function required(name: string, fallback?: string): string {
|
||||
const value = process.env[name] ?? fallback;
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`Variable d'environnement manquante : ${name}. Copie .env.example vers .env et complète-la.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function int(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const config = {
|
||||
port: int('PORT', 8080),
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
isDev,
|
||||
|
||||
adminPassword: required('ADMIN_PASSWORD', isDev ? 'admin' : undefined),
|
||||
sessionSecret: required(
|
||||
'SESSION_SECRET',
|
||||
isDev ? randomBytes(32).toString('hex') : undefined,
|
||||
),
|
||||
sessionTtlMs: int('SESSION_TTL_MS', 12 * 60 * 60 * 1000),
|
||||
|
||||
/** Vide = enrôlement automatique désactivé. */
|
||||
enrollmentToken: process.env.ENROLLMENT_TOKEN ?? '',
|
||||
|
||||
dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'),
|
||||
|
||||
statusIntervalMs: int('STATUS_INTERVAL_MS', 2000),
|
||||
agentTimeoutMs: int('AGENT_TIMEOUT_MS', 15_000),
|
||||
commandTimeoutMs: int('COMMAND_TIMEOUT_MS', 15_000),
|
||||
|
||||
/** Nombre d'entrées de journal conservées en base. */
|
||||
logRetention: int('LOG_RETENTION', 2000),
|
||||
|
||||
webDist: path.resolve(repoRoot, 'packages/web/dist'),
|
||||
} as const;
|
||||
255
packages/server/src/db.ts
Normal file
255
packages/server/src/db.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import type { LogEntry, LogLevel, ObsSettings, Platform } from '@stream-control/shared';
|
||||
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
|
||||
import { config } from './config.ts';
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
|
||||
export const db = new DatabaseSync(config.dbPath);
|
||||
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT,
|
||||
platform TEXT NOT NULL DEFAULT 'unknown',
|
||||
agent_version TEXT,
|
||||
token_hash TEXT NOT NULL,
|
||||
obs_host TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
obs_port INTEGER NOT NULL DEFAULT 4455,
|
||||
obs_password TEXT NOT NULL DEFAULT '',
|
||||
auto_connect INTEGER NOT NULL DEFAULT 1,
|
||||
notes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC);
|
||||
`);
|
||||
|
||||
export interface AgentRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
platform: string;
|
||||
agent_version: string | null;
|
||||
token_hash: string;
|
||||
obs_host: string;
|
||||
obs_port: number;
|
||||
obs_password: string;
|
||||
auto_connect: number;
|
||||
notes: string | null;
|
||||
created_at: number;
|
||||
last_seen_at: number | null;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
platform: Platform;
|
||||
agentVersion: string | null;
|
||||
tokenHash: string;
|
||||
obs: ObsSettings;
|
||||
autoConnectObs: boolean;
|
||||
notes: string | null;
|
||||
createdAt: number;
|
||||
lastSeenAt: number | null;
|
||||
}
|
||||
|
||||
function toRecord(row: AgentRow): AgentRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
hostname: row.hostname,
|
||||
platform: (row.platform as Platform) ?? 'unknown',
|
||||
agentVersion: row.agent_version,
|
||||
tokenHash: row.token_hash,
|
||||
obs: {
|
||||
host: row.obs_host || DEFAULT_OBS_SETTINGS.host,
|
||||
port: Number(row.obs_port) || DEFAULT_OBS_SETTINGS.port,
|
||||
password: row.obs_password ?? '',
|
||||
},
|
||||
autoConnectObs: Number(row.auto_connect) === 1,
|
||||
notes: row.notes,
|
||||
createdAt: Number(row.created_at),
|
||||
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
|
||||
};
|
||||
}
|
||||
|
||||
const stmts = {
|
||||
listAgents: db.prepare('SELECT * FROM agents ORDER BY name COLLATE NOCASE'),
|
||||
getAgent: db.prepare('SELECT * FROM agents WHERE id = ?'),
|
||||
getAgentByTokenHash: db.prepare('SELECT * FROM agents WHERE token_hash = ?'),
|
||||
insertAgent: db.prepare(`
|
||||
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
|
||||
obs_host, obs_port, obs_password, auto_connect, notes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
updateIdentity: db.prepare(`
|
||||
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
|
||||
WHERE id = ?
|
||||
`),
|
||||
updateSettings: db.prepare(`
|
||||
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
|
||||
auto_connect = ?, notes = ?
|
||||
WHERE id = ?
|
||||
`),
|
||||
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
|
||||
rotateToken: db.prepare('UPDATE agents SET token_hash = ? WHERE id = ?'),
|
||||
deleteAgent: db.prepare('DELETE FROM agents WHERE id = ?'),
|
||||
|
||||
insertLog: db.prepare('INSERT INTO logs (agent_id, level, message, ts) VALUES (?, ?, ?, ?)'),
|
||||
recentLogs: db.prepare(`
|
||||
SELECT l.id, l.agent_id, l.level, l.message, l.ts, a.name AS agent_name
|
||||
FROM logs l LEFT JOIN agents a ON a.id = l.agent_id
|
||||
ORDER BY l.id DESC LIMIT ?
|
||||
`),
|
||||
pruneLogs: db.prepare(`
|
||||
DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT ?)
|
||||
`),
|
||||
};
|
||||
|
||||
export const agentsRepo = {
|
||||
list(): AgentRecord[] {
|
||||
return (stmts.listAgents.all() as unknown as AgentRow[]).map(toRecord);
|
||||
},
|
||||
|
||||
get(id: string): AgentRecord | null {
|
||||
const row = stmts.getAgent.get(id) as unknown as AgentRow | undefined;
|
||||
return row ? toRecord(row) : null;
|
||||
},
|
||||
|
||||
findByTokenHash(tokenHash: string): AgentRecord | null {
|
||||
const row = stmts.getAgentByTokenHash.get(tokenHash) as unknown as AgentRow | undefined;
|
||||
return row ? toRecord(row) : null;
|
||||
},
|
||||
|
||||
create(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
hostname?: string | null;
|
||||
platform?: Platform;
|
||||
agentVersion?: string | null;
|
||||
obs?: Partial<ObsSettings>;
|
||||
autoConnectObs?: boolean;
|
||||
notes?: string | null;
|
||||
}): AgentRecord {
|
||||
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
|
||||
stmts.insertAgent.run(
|
||||
input.id,
|
||||
input.name,
|
||||
input.hostname ?? null,
|
||||
input.platform ?? 'unknown',
|
||||
input.agentVersion ?? null,
|
||||
input.tokenHash,
|
||||
obs.host,
|
||||
obs.port,
|
||||
obs.password,
|
||||
input.autoConnectObs === false ? 0 : 1,
|
||||
input.notes ?? null,
|
||||
Date.now(),
|
||||
);
|
||||
const created = agentsRepo.get(input.id);
|
||||
if (!created) throw new Error(`Échec de création de l'agent ${input.id}`);
|
||||
return created;
|
||||
},
|
||||
|
||||
updateIdentity(
|
||||
id: string,
|
||||
identity: { hostname: string | null; platform: Platform; agentVersion: string | null },
|
||||
): void {
|
||||
stmts.updateIdentity.run(
|
||||
identity.hostname,
|
||||
identity.platform,
|
||||
identity.agentVersion,
|
||||
Date.now(),
|
||||
id,
|
||||
);
|
||||
},
|
||||
|
||||
updateSettings(
|
||||
id: string,
|
||||
settings: {
|
||||
name: string;
|
||||
obs: ObsSettings;
|
||||
autoConnectObs: boolean;
|
||||
notes: string | null;
|
||||
},
|
||||
): void {
|
||||
stmts.updateSettings.run(
|
||||
settings.name,
|
||||
settings.obs.host,
|
||||
settings.obs.port,
|
||||
settings.obs.password,
|
||||
settings.autoConnectObs ? 1 : 0,
|
||||
settings.notes,
|
||||
id,
|
||||
);
|
||||
},
|
||||
|
||||
touch(id: string): void {
|
||||
stmts.touchAgent.run(Date.now(), id);
|
||||
},
|
||||
|
||||
rotateToken(id: string, tokenHash: string): void {
|
||||
stmts.rotateToken.run(tokenHash, id);
|
||||
},
|
||||
|
||||
remove(id: string): void {
|
||||
stmts.deleteAgent.run(id);
|
||||
},
|
||||
};
|
||||
|
||||
interface LogRow {
|
||||
id: number;
|
||||
agent_id: string | null;
|
||||
agent_name: string | null;
|
||||
level: string;
|
||||
message: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export const logsRepo = {
|
||||
append(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry {
|
||||
const info = stmts.insertLog.run(agentId, level, message, ts);
|
||||
if (Math.random() < 0.02) stmts.pruneLogs.run(config.logRetention);
|
||||
const agent = agentId ? agentsRepo.get(agentId) : null;
|
||||
return {
|
||||
id: Number(info.lastInsertRowid),
|
||||
agentId,
|
||||
agentName: agent?.name ?? null,
|
||||
level,
|
||||
message,
|
||||
ts,
|
||||
};
|
||||
},
|
||||
|
||||
recent(limit = 200): LogEntry[] {
|
||||
const rows = stmts.recentLogs.all(limit) as unknown as LogRow[];
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: Number(row.id),
|
||||
agentId: row.agent_id,
|
||||
agentName: row.agent_name,
|
||||
level: row.level as LogLevel,
|
||||
message: row.message,
|
||||
ts: Number(row.ts),
|
||||
}))
|
||||
.reverse();
|
||||
},
|
||||
};
|
||||
267
packages/server/src/hub.ts
Normal file
267
packages/server/src/hub.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type {
|
||||
AgentAction,
|
||||
AgentStatus,
|
||||
AgentView,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
ServerToAgent,
|
||||
ServerToDashboard,
|
||||
} from '@stream-control/shared';
|
||||
import { emptyStatus } from '@stream-control/shared';
|
||||
import { config } from './config.ts';
|
||||
import { agentsRepo, logsRepo, type AgentRecord } from './db.ts';
|
||||
|
||||
interface PendingCommand {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface AgentConnection {
|
||||
socket: WebSocket;
|
||||
lastSeenAt: number;
|
||||
pending: Map<string, PendingCommand>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point central : garde en mémoire les connexions agents + le dernier statut
|
||||
* connu, dispatche les commandes et diffuse l'état aux dashboards ouverts.
|
||||
*/
|
||||
class Hub {
|
||||
private readonly connections = new Map<string, AgentConnection>();
|
||||
private readonly statuses = new Map<string, AgentStatus>();
|
||||
private readonly dashboards = new Set<WebSocket>();
|
||||
|
||||
// --- Agents -------------------------------------------------------------
|
||||
|
||||
attachAgent(agentId: string, socket: WebSocket): void {
|
||||
// Une seule session par agent : la nouvelle connexion évince l'ancienne.
|
||||
const existing = this.connections.get(agentId);
|
||||
if (existing && existing.socket !== socket) {
|
||||
this.failPending(existing, new Error('Connexion agent remplacée'));
|
||||
try {
|
||||
existing.socket.close(4000, 'Remplacé par une nouvelle session');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
this.connections.set(agentId, { socket, lastSeenAt: Date.now(), pending: new Map() });
|
||||
this.statuses.set(agentId, this.statuses.get(agentId) ?? emptyStatus());
|
||||
this.publishAgent(agentId);
|
||||
}
|
||||
|
||||
detachAgent(agentId: string, socket: WebSocket): void {
|
||||
const connection = this.connections.get(agentId);
|
||||
if (!connection || connection.socket !== socket) return;
|
||||
this.failPending(connection, new Error('Agent déconnecté'));
|
||||
this.connections.delete(agentId);
|
||||
|
||||
// On garde le dernier statut connu mais on marque OBS comme injoignable.
|
||||
const status = this.statuses.get(agentId);
|
||||
if (status) {
|
||||
this.statuses.set(agentId, {
|
||||
...status,
|
||||
obsConnected: false,
|
||||
recording: false,
|
||||
streaming: false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
this.publishAgent(agentId);
|
||||
}
|
||||
|
||||
isOnline(agentId: string): boolean {
|
||||
return this.connections.has(agentId);
|
||||
}
|
||||
|
||||
markSeen(agentId: string): void {
|
||||
const connection = this.connections.get(agentId);
|
||||
if (connection) connection.lastSeenAt = Date.now();
|
||||
}
|
||||
|
||||
updateStatus(agentId: string, status: AgentStatus): void {
|
||||
this.statuses.set(agentId, status);
|
||||
this.markSeen(agentId);
|
||||
this.publishAgent(agentId);
|
||||
}
|
||||
|
||||
resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void {
|
||||
const pending = this.connections.get(agentId)?.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.connections.get(agentId)?.pending.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
if (ok) pending.resolve(data);
|
||||
else pending.reject(new Error(error ?? 'La commande a échoué'));
|
||||
}
|
||||
|
||||
/** Envoie une commande à un agent et attend son accusé de résultat. */
|
||||
async sendCommand(
|
||||
agentId: string,
|
||||
action: AgentAction,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const connection = this.connections.get(agentId);
|
||||
if (!connection) throw new Error('Agent hors-ligne');
|
||||
|
||||
const requestId = randomUUID();
|
||||
const message: ServerToAgent = { type: 'command', requestId, action, params };
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
connection.pending.delete(requestId);
|
||||
reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`));
|
||||
}, config.commandTimeoutMs);
|
||||
|
||||
connection.pending.set(requestId, { resolve, reject, timer });
|
||||
|
||||
try {
|
||||
connection.socket.send(JSON.stringify(message));
|
||||
} catch (err) {
|
||||
connection.pending.delete(requestId);
|
||||
clearTimeout(timer);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Pousse la configuration OBS à un agent connecté (sans attendre de réponse). */
|
||||
pushConfig(record: AgentRecord): void {
|
||||
const connection = this.connections.get(record.id);
|
||||
if (!connection) return;
|
||||
const message: ServerToAgent = {
|
||||
type: 'config',
|
||||
obs: record.obs,
|
||||
autoConnectObs: record.autoConnectObs,
|
||||
};
|
||||
connection.socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
disconnectAgent(agentId: string, reason: string): void {
|
||||
const connection = this.connections.get(agentId);
|
||||
if (!connection) return;
|
||||
try {
|
||||
connection.socket.close(4001, reason);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vues ---------------------------------------------------------------
|
||||
|
||||
statusOf(agentId: string): AgentStatus {
|
||||
return this.statuses.get(agentId) ?? emptyStatus();
|
||||
}
|
||||
|
||||
view(record: AgentRecord): AgentView {
|
||||
const online = this.isOnline(record.id);
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
hostname: record.hostname,
|
||||
platform: record.platform,
|
||||
agentVersion: record.agentVersion,
|
||||
online,
|
||||
lastSeenAt: online
|
||||
? (this.connections.get(record.id)?.lastSeenAt ?? record.lastSeenAt)
|
||||
: record.lastSeenAt,
|
||||
createdAt: record.createdAt,
|
||||
// Le mot de passe OBS n'est jamais renvoyé au navigateur.
|
||||
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
|
||||
autoConnectObs: record.autoConnectObs,
|
||||
notes: record.notes,
|
||||
status: this.statusOf(record.id),
|
||||
};
|
||||
}
|
||||
|
||||
views(): AgentView[] {
|
||||
return agentsRepo.list().map((record) => this.view(record));
|
||||
}
|
||||
|
||||
// --- Dashboards ---------------------------------------------------------
|
||||
|
||||
attachDashboard(socket: WebSocket): void {
|
||||
this.dashboards.add(socket);
|
||||
this.sendTo(socket, {
|
||||
type: 'snapshot',
|
||||
agents: this.views(),
|
||||
logs: logsRepo.recent(200),
|
||||
});
|
||||
}
|
||||
|
||||
detachDashboard(socket: WebSocket): void {
|
||||
this.dashboards.delete(socket);
|
||||
}
|
||||
|
||||
publishAgent(agentId: string): void {
|
||||
const record = agentsRepo.get(agentId);
|
||||
if (!record) {
|
||||
this.broadcast({ type: 'agent.removed', agentId });
|
||||
return;
|
||||
}
|
||||
this.broadcast({ type: 'agent', agent: this.view(record) });
|
||||
}
|
||||
|
||||
publishRemoval(agentId: string): void {
|
||||
this.statuses.delete(agentId);
|
||||
this.broadcast({ type: 'agent.removed', agentId });
|
||||
}
|
||||
|
||||
/** Journalise un évènement : persistance + diffusion temps réel. */
|
||||
log(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry {
|
||||
const entry = logsRepo.append(agentId, level, message, ts);
|
||||
this.broadcast({ type: 'log', entry });
|
||||
if (level === 'error' || level === 'warn') {
|
||||
console.warn(`[${level}] ${entry.agentName ?? 'serveur'} — ${message}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private broadcast(message: ServerToDashboard): void {
|
||||
const payload = JSON.stringify(message);
|
||||
for (const socket of this.dashboards) {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private sendTo(socket: WebSocket, message: ServerToDashboard): void {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
private failPending(connection: AgentConnection, error: Error): void {
|
||||
for (const pending of connection.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
connection.pending.clear();
|
||||
}
|
||||
|
||||
/** Coupe les agents silencieux : le heartbeat n'arrive plus. */
|
||||
reapStale(): void {
|
||||
const deadline = Date.now() - config.agentTimeoutMs;
|
||||
for (const [agentId, connection] of this.connections) {
|
||||
if (connection.lastSeenAt < deadline) {
|
||||
this.log(agentId, 'warn', 'Agent silencieux, fermeture de la session');
|
||||
try {
|
||||
connection.socket.terminate();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.detachAgent(agentId, connection.socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pingAll(): void {
|
||||
const payload = JSON.stringify({ type: 'ping', ts: Date.now() } satisfies ServerToAgent);
|
||||
for (const connection of this.connections.values()) {
|
||||
if (connection.socket.readyState === connection.socket.OPEN) {
|
||||
connection.socket.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hub = new Hub();
|
||||
111
packages/server/src/index.ts
Normal file
111
packages/server/src/index.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { config } from './config.ts';
|
||||
import { verifySession } from './auth.ts';
|
||||
import { api } from './api.ts';
|
||||
import { hub } from './hub.ts';
|
||||
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.json({ ok: true, agents: hub.views().filter((agent) => agent.online).length });
|
||||
});
|
||||
|
||||
app.use('/api', api);
|
||||
|
||||
// Le dashboard compilé, s'il a été construit (npm run build -w @stream-control/web).
|
||||
if (fs.existsSync(config.webDist)) {
|
||||
app.use(express.static(config.webDist));
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
|
||||
res.sendFile(path.join(config.webDist, 'index.html'));
|
||||
});
|
||||
} else {
|
||||
app.get('/', (_req, res) => {
|
||||
res
|
||||
.status(200)
|
||||
.type('text/plain')
|
||||
.send(
|
||||
'Dashboard non compilé.\n' +
|
||||
'Développement : npm run dev (Vite sur http://localhost:5173)\n' +
|
||||
'Production : npm run build puis npm start',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// --- WebSockets : deux points d'entrée, authentifiés à l'upgrade -------------
|
||||
|
||||
const agentWss = new WebSocketServer({ noServer: true });
|
||||
const dashboardWss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
|
||||
if (url.pathname === '/ws/agent') {
|
||||
const auth = authenticateAgent(req);
|
||||
if (!auth) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
agentWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const remote = String(
|
||||
req.headers['x-forwarded-for'] ?? req.socket.remoteAddress ?? 'inconnu',
|
||||
).split(',')[0]!.trim();
|
||||
handleAgentConnection(ws, auth, remote);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/ws/dashboard') {
|
||||
if (!verifySession(url.searchParams.get('token'))) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
dashboardWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hub.attachDashboard(ws);
|
||||
ws.on('close', () => hub.detachDashboard(ws));
|
||||
ws.on('error', () => hub.detachDashboard(ws));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
// --- Boucles de maintenance -------------------------------------------------
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
hub.pingAll();
|
||||
hub.reapStale();
|
||||
}, 5000);
|
||||
heartbeat.unref();
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`stream-control · http://${config.host}:${config.port}`);
|
||||
console.log(` agents → ws://${config.host}:${config.port}/ws/agent`);
|
||||
console.log(` enrôlement → ${config.enrollmentToken ? 'activé' : 'désactivé'}`);
|
||||
if (config.isDev && !process.env.ADMIN_PASSWORD) {
|
||||
console.warn(' ⚠ ADMIN_PASSWORD non défini, mot de passe de développement : « admin »');
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown(signal: string): void {
|
||||
console.log(`\n${signal} reçu, arrêt…`);
|
||||
clearInterval(heartbeat);
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
Reference in New Issue
Block a user