This commit is contained in:
jeanotx32
2026-08-11 00:26:56 +02:00
commit 6f11b72cbb
43 changed files with 7175 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
{
"name": "@stream-control/server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc -b",
"dev": "node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts",
"start": "node --disable-warning=ExperimentalWarning dist/index.js"
},
"dependencies": {
"@stream-control/shared": "*",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.5",
"@types/ws": "^8.5.13",
"typescript": "^5.7.3"
}
}

View 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
View 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,
};
}

View 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();
}

View 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
View 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
View 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();

View 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'));

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}