diff --git a/app/web/lib/store.js b/app/web/lib/store.js
index 1e3e9ae..30636d9 100644
--- a/app/web/lib/store.js
+++ b/app/web/lib/store.js
@@ -20,7 +20,11 @@ function _save(id) {
const payload = JSON.stringify({
id, createdAt: s.createdAt, lastAccess: s.lastAccess, status: s.status,
data: s.data || null,
+ originalFlows: s.originalFlows || null,
fortiConfig: s.fortiConfig || null,
+ telemetryContextId: s.telemetryContextId || null,
+ telemetryAssociation: s.telemetryAssociation || null,
+ fortiConfigContextId: s.fortiConfigContextId || null,
});
const tmp = _cachePath(id) + '.' + Date.now() + '.tmp';
fs.writeFile(tmp, payload, 'utf8', (err) => {
@@ -53,7 +57,13 @@ function _loadAll() {
lastAccess: payload.lastAccess,
status: payload.status,
data: payload.data,
+ originalFlows: payload.originalFlows || null,
fortiConfig: payload.fortiConfig,
+ telemetryContextId: payload.telemetryContextId || crypto.randomBytes(16).toString('hex'),
+ telemetryAssociation: payload.telemetryAssociation || null,
+ fortiConfigRawText: null,
+ fortiConfigContextId: payload.fortiConfigContextId || null,
+ pendingFortiConfig: null,
error: null,
emitter: new EventEmitter(),
progress: { lines: 0, linesPerSec: 0, eta: null },
@@ -77,16 +87,22 @@ function evictOldest() {
if (oldestId) deleteSession(oldestId);
}
-function createSession() {
+function createSession(options = {}) {
evictOldest();
const id = crypto.randomBytes(16).toString('hex');
sessions.set(id, {
id,
createdAt: Date.now(),
lastAccess: Date.now(),
+ telemetryContextId: options.telemetryContextId || crypto.randomBytes(16).toString('hex'),
status: 'parsing',
data: null,
+ originalFlows: null,
error: null,
+ telemetryAssociation: null,
+ fortiConfigRawText: null,
+ fortiConfigContextId: null,
+ pendingFortiConfig: null,
emitter: new EventEmitter(),
progress: { lines: 0, linesPerSec: 0, eta: null },
});
@@ -103,6 +119,7 @@ function setSessionData(id, data, options = {}) {
const s = sessions.get(id);
if (s) {
s.data = data;
+ if (options.originalFlows) s.originalFlows = options.originalFlows;
s.status = 'ready';
s.lastAccess = Date.now();
if (options.persist !== false) _save(id);
@@ -118,6 +135,46 @@ function setFortiConfig(id, fortiConfig) {
if (s) { s.fortiConfig = fortiConfig; _save(id); }
}
+function setTelemetryContextId(id, telemetryContextId) {
+ const s = sessions.get(id);
+ if (s && telemetryContextId) {
+ s.telemetryContextId = String(telemetryContextId);
+ _save(id);
+ }
+}
+
+function setTelemetryAssociation(id, telemetryAssociation) {
+ const s = sessions.get(id);
+ if (s) {
+ s.telemetryAssociation = telemetryAssociation || null;
+ _save(id);
+ }
+}
+
+function setPendingFortiConfig(id, pendingFortiConfig) {
+ const s = sessions.get(id);
+ if (s) {
+ s.pendingFortiConfig = pendingFortiConfig || null;
+ _save(id);
+ }
+}
+
+function setFortiConfigRawText(id, rawText) {
+ const s = sessions.get(id);
+ if (s) {
+ s.fortiConfigRawText = rawText || null;
+ _save(id);
+ }
+}
+
+function setFortiConfigContextId(id, configContextId) {
+ const s = sessions.get(id);
+ if (s) {
+ s.fortiConfigContextId = configContextId || null;
+ _save(id);
+ }
+}
+
function setSessionError(id, error) {
const s = sessions.get(id);
if (s) { s.error = error; s.status = 'error'; }
@@ -155,7 +212,7 @@ setInterval(() => {
try { fs.unlink(_cachePath(id), () => {}); } catch { /* ignore */ }
}
}
-}, PURGE_INTERVAL);
+}, PURGE_INTERVAL).unref();
// ─── Load persisted sessions on startup ───────────────────────────────────────
_loadAll();
@@ -177,6 +234,11 @@ module.exports = {
getSession,
setSessionData,
setFortiConfig,
+ setTelemetryContextId,
+ setTelemetryAssociation,
+ setPendingFortiConfig,
+ setFortiConfigRawText,
+ setFortiConfigContextId,
setSessionError,
deleteSession,
getSessionCachePath,
diff --git a/app/web/lib/telemetry-association.js b/app/web/lib/telemetry-association.js
new file mode 100644
index 0000000..18782a6
--- /dev/null
+++ b/app/web/lib/telemetry-association.js
@@ -0,0 +1,176 @@
+'use strict';
+
+const {
+ collectTelemetryIdentity,
+ normalizeConfigIdentity,
+ validateConfigTelemetryConsistency,
+} = require('./config-consistency');
+
+const ASSOCIATION_MATCHED = 'matched';
+const ASSOCIATION_CONFIRMATION_REQUIRED = 'confirmation_required';
+const ASSOCIATION_SELECTION_REQUIRED = 'selection_required';
+const ASSOCIATION_CONTRADICTION = 'contradiction';
+const CONFIG_TELEMETRY_ASSOCIATION_REQUIRED = 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED';
+const CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED = 'CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED';
+const CONFIG_TELEMETRY_MISMATCH = 'CONFIG_TELEMETRY_MISMATCH';
+
+function createConfirmedTelemetryAssociation({
+ telemetryDeviceName,
+ configHostname,
+ telemetryContextId,
+ configContextId,
+ confirmedAt = new Date().toISOString(),
+}) {
+ return {
+ telemetryDeviceName,
+ configHostname,
+ telemetryContextId,
+ configContextId,
+ confirmedByUser: true,
+ confirmedAt,
+ };
+}
+
+function createSelectedTelemetryAssociation({
+ telemetryDeviceName,
+ configHostname,
+ telemetryContextId,
+ configContextId,
+}) {
+ return {
+ telemetryDeviceName,
+ configHostname,
+ telemetryContextId,
+ configContextId,
+ selectedByUser: true,
+ confirmedByUser: false,
+ confirmedAt: null,
+ };
+}
+
+function isTelemetryAssociationUsable(association, context = {}) {
+ return Boolean(
+ (association?.confirmedByUser || association?.selectedByUser)
+ && association.telemetryDeviceName
+ && (association.confirmedByUser ? association.configHostname : true)
+ && association.telemetryContextId === context.telemetryContextId
+ && association.configContextId === context.configContextId
+ && (context.telemetryDeviceName === undefined || association.telemetryDeviceName === context.telemetryDeviceName)
+ && (context.configHostname === undefined || association.configHostname === context.configHostname),
+ );
+}
+
+function refuseTelemetryConfigAssociation() {
+ return {
+ status: 'unassociated',
+ code: 'CONFIG_TELEMETRY_ASSOCIATION_REFUSED',
+ association: null,
+ };
+}
+
+function evaluateTelemetryConfigAssociation(flows, fortiConfig = {}, context = {}, existingAssociation = null) {
+ const telemetryIdentity = collectTelemetryIdentity(flows);
+ const configIdentity = normalizeConfigIdentity(fortiConfig);
+ const telemetryDeviceNames = telemetryIdentity.devnames;
+ const selected = existingAssociation?.telemetryDeviceName || context.telemetryDeviceName || null;
+ const telemetryDeviceName = selected || (telemetryDeviceNames.length === 1 ? telemetryDeviceNames[0] : null);
+ const scopedFlows = selected
+ ? (Array.isArray(flows) ? flows : []).filter(flow => {
+ const scope = flow?.scope && typeof flow.scope === 'object' ? flow.scope : {};
+ return String(flow?.devname || scope.devname || '').trim() === selected;
+ })
+ : flows;
+ const validation = validateConfigTelemetryConsistency(scopedFlows, fortiConfig);
+ const base = {
+ telemetryDeviceNames,
+ telemetryDeviceName,
+ configHostname: configIdentity.hostname,
+ telemetryContextId: context.telemetryContextId || null,
+ configContextId: context.configContextId || null,
+ validation,
+ };
+
+ if (telemetryDeviceNames.length > 1 && !selected) {
+ return {
+ ...base,
+ status: ASSOCIATION_SELECTION_REQUIRED,
+ code: CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED,
+ requiresConfirmation: true,
+ };
+ }
+ if (selected && !telemetryDeviceNames.includes(selected)) {
+ return {
+ ...base,
+ status: ASSOCIATION_CONTRADICTION,
+ code: CONFIG_TELEMETRY_MISMATCH,
+ requiresConfirmation: false,
+ };
+ }
+ if (configIdentity.hostname && telemetryDeviceName && configIdentity.hostname !== telemetryDeviceName) {
+ const hostnameMismatch = `hostname config=${configIdentity.hostname}; télémétrie=${telemetryDeviceName}`;
+ const confirmationSuppliesOnlyMissingProof = 'aucune preuve positive d’identité ou de correspondance interface-réseau';
+ const confirmableNameMismatch = validation.errors.length > 0
+ && validation.errors.every(error => (error.details || []).every(detail =>
+ detail === hostnameMismatch || detail === confirmationSuppliesOnlyMissingProof
+ ));
+ if (validation.errors.length > 0 && !confirmableNameMismatch) {
+ return {
+ ...base,
+ status: ASSOCIATION_CONTRADICTION,
+ code: CONFIG_TELEMETRY_MISMATCH,
+ requiresConfirmation: false,
+ };
+ }
+ if (existingAssociation?.confirmedByUser
+ && isTelemetryAssociationUsable(existingAssociation, {
+ ...context,
+ telemetryDeviceName,
+ configHostname: configIdentity.hostname,
+ })) {
+ if (confirmableNameMismatch) base.validation = { ...validation, ok: true, errors: [], message: null };
+ return {
+ ...base,
+ status: ASSOCIATION_MATCHED,
+ requiresConfirmation: false,
+ confirmedByUser: true,
+ };
+ }
+ if (confirmableNameMismatch) {
+ base.validation = { ...validation, ok: true, errors: [], message: null };
+ }
+ return {
+ ...base,
+ status: ASSOCIATION_CONFIRMATION_REQUIRED,
+ code: CONFIG_TELEMETRY_ASSOCIATION_REQUIRED,
+ requiresConfirmation: true,
+ };
+ }
+ if (!validation.ok) {
+ return {
+ ...base,
+ status: ASSOCIATION_CONTRADICTION,
+ code: CONFIG_TELEMETRY_MISMATCH,
+ requiresConfirmation: false,
+ };
+ }
+ return {
+ ...base,
+ status: ASSOCIATION_MATCHED,
+ requiresConfirmation: false,
+ };
+}
+
+module.exports = {
+ ASSOCIATION_MATCHED,
+ ASSOCIATION_CONFIRMATION_REQUIRED,
+ ASSOCIATION_SELECTION_REQUIRED,
+ ASSOCIATION_CONTRADICTION,
+ CONFIG_TELEMETRY_ASSOCIATION_REQUIRED,
+ CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED,
+ CONFIG_TELEMETRY_MISMATCH,
+ createConfirmedTelemetryAssociation,
+ createSelectedTelemetryAssociation,
+ isTelemetryAssociationUsable,
+ refuseTelemetryConfigAssociation,
+ evaluateTelemetryConfigAssociation,
+};
diff --git a/app/web/public/app.js b/app/web/public/app.js
index 6fb5fe8..78e38e5 100644
--- a/app/web/public/app.js
+++ b/app/web/public/app.js
@@ -299,8 +299,17 @@ async function loadWsFromHistory(id) {
try {
const r = await fetch(`/api/workspaces/${id}`);
if (!r.ok) { const e = await r.json().catch(() => ({})); alert(e.error || 'Erreur chargement'); return; }
- const { sessionId } = await r.json();
+ const { sessionId, fortiConfig, telemetryAssociation } = await r.json();
state.session = sessionId;
+ if (fortiConfig) {
+ deployState.fortiConfig = fortiConfig;
+ deployState.telemetryAssociation = telemetryAssociation || fortiConfig.telemetryAssociation || null;
+ deployState.wizardStep = Math.max(deployState.wizardStep, 2);
+ try {
+ const ir = await fetch(`/api/deploy/interfaces?session=${sessionId}`);
+ if (ir.ok) deployState.interfaces = await ir.json();
+ } catch {}
+ }
try {
const sr = await fetch(`/api/stats?session=${sessionId}`);
if (sr.ok) { const d = await sr.json(); state.stats = d.stats; state.meta = d.meta; }
@@ -2092,6 +2101,7 @@ async function denied() {
// Deploy state (persists across nav changes within a session)
const deployState = {
fortiConfig: null,
+ telemetryAssociation: null,
interfaces: null,
analyzed: null,
searchFilter: '',
@@ -2273,6 +2283,7 @@ async function exportSession() {
...serverData,
deployState: {
fortiConfig: deployState.fortiConfig,
+ telemetryAssociation: deployState.telemetryAssociation,
analyzed: serializeAnalyzed(deployState.analyzed),
baseAnalyzedPolicies: serializeAnalyzed(deployState.baseAnalyzedPolicies),
selected: [...deployState.selected],
@@ -2352,6 +2363,7 @@ function importSession(file) {
if (data.deployState) {
const ds = data.deployState;
deployState.fortiConfig = ds.fortiConfig || null;
+ deployState.telemetryAssociation = ds.telemetryAssociation || data.telemetryAssociation || null;
deployState.analyzed = deserializeAnalyzed(ds.analyzed) || null;
deployState.baseAnalyzedPolicies = deserializeAnalyzed(ds.baseAnalyzedPolicies || ds.analyzed) || null;
deployState.selected = new Set(ds.selected || []);
@@ -4578,6 +4590,7 @@ async function deploy() {
deployState.fortiConfig = null;
deployState.interfaces = null;
deployState.analyzed = null;
+ deployState.telemetryAssociation = null;
deployState.selected = new Set();
deploy();
});
@@ -4591,12 +4604,18 @@ async function deploy() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vdom }),
});
- const data = await r.json();
- if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
- deployState.fortiConfig = data;
- const ir = await fetch(`/api/deploy/interfaces?session=${state.session}`);
- if (ir.ok) deployState.interfaces = await ir.json();
- deploy();
+ const data = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ if (data.code === 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED'
+ || data.code === 'CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED') {
+ const resolved = await resolveTelemetryAssociation(data);
+ if (resolved && resolved.status !== 'unassociated') await applyConfigSummary(resolved);
+ else resetDeployConfigChoice();
+ return;
+ }
+ throw new Error(data.error || `HTTP ${r.status}`);
+ }
+ await applyConfigSummary(data);
} catch (err) {
alert('Erreur changement VDOM : ' + err.message);
}
@@ -4942,7 +4961,12 @@ function renderConfSummary(cfg) {
? `
${cfg.selectedVdom || 'root'}VDOM
`
: ''}
- `;
+
+ ${cfg.telemetryAssociation
+ ? `✓ Télémétrie associée : ${escHtml(cfg.telemetryAssociation.telemetryDeviceName || '')} · configuration : ${escHtml(cfg.telemetryAssociation.configHostname || cfg.hostname || '')}
`
+ : cfg.hostname
+ ? `Configuration : ${escHtml(cfg.hostname)}
`
+ : ''}`;
}
// ─── Dynamic routes panel ────────────────────────────────────────────────────
@@ -5123,6 +5147,115 @@ function refreshIfacePanel() {
if (body) body['innerHTML'] = renderInterfaces(deployState.interfaces);
}
+function resetDeployConfigChoice() {
+ deployState.fortiConfig = null;
+ deployState.interfaces = null;
+ deployState.analyzed = null;
+ deployState.selected = new Set();
+ deployState.telemetryAssociation = null;
+ deployState.wizardStep = 1;
+ deploy();
+}
+
+async function applyConfigSummary(data) {
+ deployState.fortiConfig = data;
+ deployState.telemetryAssociation = data.telemetryAssociation || null;
+ deployState.addressSelectionMismatch = null;
+ const ir = await fetch(`/api/deploy/interfaces?session=${state.session}`);
+ if (ir.ok) {
+ deployState.interfaces = await ir.json();
+ // Auto-select first SDWAN zone as default
+ if (deployState.interfaces?.sdwanEnabled) {
+ const zones = deployState.interfaces.sdwanZoneNames;
+ deployState.selectedSdwan = (zones && zones.length > 0)
+ ? zones[0]
+ : (deployState.interfaces.sdwanIntfName || null);
+ } else {
+ deployState.selectedSdwan = null;
+ }
+ }
+ deploy();
+}
+
+function showTelemetryAssociationModal(details) {
+ return new Promise(resolve => {
+ const selection = details.code === 'CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED';
+ const names = details.association?.telemetryDeviceNames || [];
+ const telemetryName = details.association?.telemetryDeviceName || '';
+ const configHostname = details.association?.configHostname || '';
+ const overlay = document.createElement('div');
+ overlay.className = 'telemetry-association-overlay';
+ overlay.innerHTML = selection
+ ? `
+
Quel équipement détecté correspond à cette configuration ?
+
Plusieurs équipements sont présents dans la télémétrie. Aucun mélange automatique ne sera effectué.
+
+
+
+
+
+
`
+ : `
+
Nom d’équipement différent
+
Impossible de confirmer automatiquement cette association.
+
+ - Nom télémétrie
- ${escHtml(telemetryName)}
+ - Hostname configuration
- ${escHtml(configHostname)}
+
+
Vérifiez qu’il s’agit bien du même équipement avant de continuer.
+
+
+
+
+
`;
+ document.body.appendChild(overlay);
+ const finish = value => { overlay.remove(); resolve(value); };
+ overlay.querySelector('#telemetry-association-refuse')?.addEventListener('click', () => finish({ action: 'refuse' }));
+ overlay.querySelector('#telemetry-association-select')?.addEventListener('click', () => finish({
+ action: 'select',
+ telemetryDeviceName: overlay.querySelector('#telemetry-association-device')?.value || '',
+ }));
+ overlay.querySelector('#telemetry-association-confirm')?.addEventListener('click', () => finish({
+ action: 'confirm', telemetryDeviceName: telemetryName,
+ }));
+ overlay.addEventListener('click', e => { if (e.target === overlay) finish({ action: 'refuse' }); });
+ });
+}
+
+async function resolveTelemetryAssociation(details) {
+ let current = details;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const choice = await showTelemetryAssociationModal(current);
+ if (!choice) return null;
+ try {
+ const r = await fetch(`/api/deploy/config-association?session=${state.session}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...choice,
+ pendingConfigId: current.pendingConfigId || details.pendingConfigId,
+ }),
+ });
+ const data = await r.json().catch(() => ({}));
+ if (r.ok) return data;
+ if (data.code === 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED'
+ && data.status === 'confirmation_required'
+ && choice.action === 'select') {
+ current = data;
+ continue;
+ }
+ alert(data.error || `HTTP ${r.status}`);
+ return null;
+ } catch (err) {
+ alert('Erreur association : ' + err.message);
+ return null;
+ }
+ }
+ return null;
+}
+
async function uploadConf(file) {
if (!file) return;
const form = new FormData();
@@ -5131,30 +5264,19 @@ async function uploadConf(file) {
try {
const r = await fetch(`/api/deploy/config-upload?session=${state.session}`, { method: 'POST', body: form });
+ const data = await r.json().catch(() => ({}));
if (!r.ok) {
- const text = await r.text();
- const msg = (() => { try { return JSON.parse(text).error; } catch { return `HTTP ${r.status}`; } })();
- alert('Erreur upload : ' + msg);
- return;
- }
- deployState.fortiConfig = await r.json();
-
- // Load interfaces
- const ir = await fetch(`/api/deploy/interfaces?session=${state.session}`);
- if (ir.ok) {
- deployState.interfaces = await ir.json();
- // Auto-select first SDWAN zone as default
- if (deployState.interfaces?.sdwanEnabled) {
- const zones = deployState.interfaces.sdwanZoneNames;
- deployState.selectedSdwan = (zones && zones.length > 0)
- ? zones[0]
- : (deployState.interfaces.sdwanIntfName || null);
- } else {
- deployState.selectedSdwan = null;
+ if (data.code === 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED'
+ || data.code === 'CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED') {
+ const resolved = await resolveTelemetryAssociation(data);
+ if (resolved && resolved.status !== 'unassociated') await applyConfigSummary(resolved);
+ else resetDeployConfigChoice();
+ return;
}
+ alert('Erreur upload : ' + (data.error || `HTTP ${r.status}`));
+ return;
}
-
- deploy(); // re-render
+ await applyConfigSummary(data);
} catch (err) {
alert('Erreur : ' + err.message);
}
diff --git a/app/web/public/style.css b/app/web/public/style.css
index ed44cbc..9bbad22 100644
--- a/app/web/public/style.css
+++ b/app/web/public/style.css
@@ -1163,6 +1163,25 @@ td.mono { font-family: var(--mono); }
.deploy-wrap { display: flex; flex-direction: column; gap: 20px; padding: 32px 36px; }
+.telemetry-association-overlay {
+ position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center;
+ padding: 24px; background: rgba(8, 8, 14, 0.72);
+}
+.telemetry-association-modal {
+ width: min(560px, 100%); padding: 24px; background: var(--bg1); border: 1px solid var(--brand);
+ border-radius: 8px; box-shadow: 0 18px 60px rgba(0, 0, 0, .45); color: var(--text);
+}
+.telemetry-association-modal h3 { margin: 0 0 14px; font-size: 17px; }
+.telemetry-association-modal p { margin: 8px 0; color: var(--text2); font-size: 13px; line-height: 1.5; }
+.telemetry-association-warning { color: var(--warn) !important; font-weight: 600; }
+.telemetry-association-modal dl { display: grid; grid-template-columns: 180px 1fr; gap: 8px 16px; margin: 18px 0; padding: 14px; background: var(--bg2); border-radius: 5px; }
+.telemetry-association-modal dt { color: var(--text2); font-size: 11px; }
+.telemetry-association-modal dd { margin: 0; font-size: 12px; }
+.telemetry-association-field { display: flex; flex-direction: column; gap: 6px; margin: 18px 0; color: var(--text2); font-size: 11px; }
+.telemetry-association-field select { background: var(--bg0); border: 1px solid var(--border2); border-radius: 4px; color: var(--text); padding: 8px; }
+.telemetry-association-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; flex-wrap: wrap; }
+.telemetry-association-inline { margin-top: 14px; padding: 8px 12px; color: var(--success); background: rgba(74,158,114,.08); border: 1px solid rgba(74,158,114,.22); border-radius: 5px; font-size: 11px; }
+
.deploy-step { background: var(--bg1); border: 1px solid var(--border); border-radius: 6px; min-width: 0; }
.deploy-step-header {
diff --git a/app/web/server.js b/app/web/server.js
index 3271e76..a2db76d 100644
--- a/app/web/server.js
+++ b/app/web/server.js
@@ -4,11 +4,14 @@ const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
+const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { buildAnalysis, consolidatePolicies, flowDecision, isExpectedOneWayFlow, buildPolicyEngineV2 } = require('./lib/analyzer');
const { AnalysisPool } = require('./lib/analysis-pool');
const { createSession, getSession, setSessionData, setFortiConfig,
+ setTelemetryAssociation, setPendingFortiConfig, setFortiConfigRawText,
+ setFortiConfigContextId,
setSessionError, deleteSession, getSessionCachePath,
getStats, listSessions } = require('./lib/store');
const { parseFortiConfig, analyzePolicies,
@@ -22,10 +25,19 @@ const { parseTrafficScopeQuery, trafficScopeKey } = require('./lib/traf
const { bindPolicyEngineV2Selections } = require('./lib/policy-binding');
const {
validateConfigTelemetryConsistency,
+ normalizeConfigIdentity,
selectTelemetryVdom,
CONFIG_TELEMETRY_MISMATCH,
CONFIG_TELEMETRY_MISMATCH_MESSAGE,
} = require('./lib/config-consistency');
+const {
+ evaluateTelemetryConfigAssociation,
+ createConfirmedTelemetryAssociation,
+ createSelectedTelemetryAssociation,
+ isTelemetryAssociationUsable,
+ CONFIG_TELEMETRY_ASSOCIATION_REQUIRED,
+ CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED,
+} = require('./lib/telemetry-association');
const app = express();
const TRUST_PROXY = (process.env.FORTIFLOW_TRUST_PROXY || '').trim();
@@ -51,7 +63,7 @@ const isoDay = value => {
// ─── Upload storage ───────────────────────────────────────────────────────────
-const UPLOAD_DIR = path.join(__dirname, 'uploads');
+const UPLOAD_DIR = path.resolve(process.env.FORTIFLOW_UPLOAD_DIR || path.join(__dirname, 'uploads'));
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const configuredUploadMb = Number.parseInt(process.env.MAX_UPLOAD_SIZE_MB || '2048', 10);
@@ -201,6 +213,7 @@ app.use([
'/api/import/workspace',
'/api/import/policies-xlsx',
'/api/deploy/config-upload',
+ '/api/deploy/config-association',
'/api/deploy/dynamic-routes',
], sessionLimiter);
@@ -263,19 +276,63 @@ function extractKnownSubnets(fortiConfig) {
return [...byCidr.values()].sort((a, b) => b.prefix - a.prefix);
}
-function flowsForFortiConfig(flows, fortiConfig) {
+function telemetryFlowDeviceName(flow) {
+ const scope = flow?.scope && typeof flow.scope === 'object' ? flow.scope : {};
+ return String(flow?.devname || scope.devname || '').trim();
+}
+
+function flowsForFortiConfig(flows, fortiConfig, telemetryAssociation = null) {
const list = Array.isArray(flows) ? flows : [];
const selectedVdom = fortiConfig?.selectedVdom;
- if (!selectedVdom) return list;
- const hasVdomMetadata = list.some(flow => String(flow.vdom || '').trim());
- if (!hasVdomMetadata) return list;
- return list.filter(flow => String(flow.vdom || '').trim() === selectedVdom);
+ let scoped = list;
+ if (selectedVdom) {
+ const hasVdomMetadata = list.some(flow => String(flow.vdom || '').trim());
+ if (hasVdomMetadata) scoped = scoped.filter(flow => String(flow.vdom || '').trim() === selectedVdom);
+ }
+ if (telemetryAssociation?.telemetryDeviceName) {
+ scoped = scoped.filter(flow => telemetryFlowDeviceName(flow) === telemetryAssociation.telemetryDeviceName);
+ }
+ return scoped;
+}
+
+function getSessionTelemetryAssociation(session, fortiConfig = session?.fortiConfig) {
+ const association = session?.telemetryAssociation;
+ if (!association) return null;
+ const sourceFlows = session?.originalFlows || session?.data?.flows || [];
+ const observedNames = new Set(sourceFlows.map(telemetryFlowDeviceName).filter(Boolean));
+ const configHostname = normalizeConfigIdentity(fortiConfig || {}).hostname;
+ const context = {
+ telemetryContextId: session.telemetryContextId,
+ configContextId: session.fortiConfigContextId || association.configContextId,
+ telemetryDeviceName: association.telemetryDeviceName,
+ configHostname,
+ };
+ if (!observedNames.has(association.telemetryDeviceName)
+ || !isTelemetryAssociationUsable(association, context)) {
+ session.telemetryAssociation = null;
+ setTelemetryAssociation(session.id, null);
+ return null;
+ }
+ return association;
}
function validateConfigTelemetryForSession(session, fortiConfig = session?.fortiConfig) {
const sourceFlows = session?.originalFlows || session?.data?.flows || [];
- const flows = flowsForFortiConfig(sourceFlows, fortiConfig || {});
- return validateConfigTelemetryConsistency(flows, fortiConfig || {});
+ const association = getSessionTelemetryAssociation(session, fortiConfig || {});
+ const scopedFlows = flowsForFortiConfig(sourceFlows, fortiConfig, association);
+ const context = {
+ telemetryContextId: session?.telemetryContextId || null,
+ configContextId: session?.fortiConfigContextId || association?.configContextId || null,
+ };
+ const decision = evaluateTelemetryConfigAssociation(scopedFlows, fortiConfig || {}, context, association);
+ const validation = decision.validation || {};
+ return {
+ ...validation,
+ ok: decision.status === 'matched' && validation.ok,
+ association: decision,
+ associationRequired: decision.status === 'confirmation_required'
+ || decision.status === 'selection_required',
+ };
}
function sendConfigTelemetryMismatch(res, validation) {
@@ -288,9 +345,31 @@ function sendConfigTelemetryMismatch(res, validation) {
});
}
+function sendTelemetryAssociationRequired(res, validation, pending = null) {
+ const association = validation.association || {};
+ const selectionRequired = association.status === 'selection_required';
+ return res.status(409).json({
+ error: selectionRequired
+ ? 'Plusieurs équipements sont présents dans la télémétrie : sélectionnez celui correspondant à la configuration.'
+ : 'La correspondance entre le nom télémétrie et le hostname de configuration doit être confirmée.',
+ code: association.code || CONFIG_TELEMETRY_ASSOCIATION_REQUIRED,
+ status: association.status,
+ association: {
+ telemetryDeviceNames: association.telemetryDeviceNames || [],
+ telemetryDeviceName: association.telemetryDeviceName || null,
+ configHostname: association.configHostname || null,
+ },
+ pendingConfigId: pending?.id || null,
+ warnings: validation.warnings || [],
+ });
+}
+
function assertConfigTelemetry(session, fortiConfig, res) {
const validation = validateConfigTelemetryForSession(session, fortiConfig);
- if (!validation.ok && res) sendConfigTelemetryMismatch(res, validation);
+ if (!validation.ok && res) {
+ if (validation.associationRequired) sendTelemetryAssociationRequired(res, validation);
+ else sendConfigTelemetryMismatch(res, validation);
+ }
return validation;
}
@@ -322,8 +401,21 @@ function sendPolicyBindingFailure(res, binding) {
});
}
-function validateWorkspaceConfigTelemetry(data, fortiConfig) {
- return validateConfigTelemetryConsistency(data?.flows || [], fortiConfig || {});
+function validateWorkspaceConfigTelemetry(data, fortiConfig, association = null, context = {}) {
+ const decision = evaluateTelemetryConfigAssociation(
+ data?.flows || [],
+ fortiConfig || {},
+ context,
+ association,
+ );
+ const validation = decision.validation || {};
+ return {
+ ...validation,
+ ok: decision.status === 'matched' && validation.ok,
+ association: decision,
+ associationRequired: decision.status === 'confirmation_required'
+ || decision.status === 'selection_required',
+ };
}
function getPolicyEngineResult(session, profile = 'recommended', fortiConfig = session.fortiConfig || {}, trafficScope = { mode: 'all' }) {
@@ -392,6 +484,12 @@ function validateWorkspaceBody(body) {
if (body.data.flows != null && !Array.isArray(body.data.flows)) {
throw new Error('Liste de flux invalide');
}
+ if (body.originalFlows != null && !Array.isArray(body.originalFlows)) {
+ throw new Error('Liste de flux télémétrie originale invalide');
+ }
+ if ((body.originalFlows?.length || 0) > 2000000) {
+ throw new Error('Workspace trop volumineux : plus de 2 000 000 flux télémétrie');
+ }
if ((body.data.flows?.length || 0) > 2000000) {
throw new Error('Workspace trop volumineux : plus de 2 000 000 flux agrégés');
}
@@ -411,6 +509,127 @@ function stripLegacyNetworkDecisions(data) {
return sanitized;
}
+function newConfigContextId() {
+ return crypto.randomBytes(16).toString('hex');
+}
+
+function buildFortiConfigSummary(fortiConfig, session) {
+ return {
+ addresses: Object.keys(fortiConfig.addresses || {}).length,
+ addrGroups: Object.keys(fortiConfig.addressGroups || {}).length,
+ services: Object.keys(fortiConfig.customServices || {}).length,
+ serviceGroups: Object.keys(fortiConfig.serviceGroups || {}).length,
+ interfaces: Object.keys(fortiConfig.interfaces || {}).length,
+ zones: Object.keys(fortiConfig.zones || {}).length,
+ sdwan: (fortiConfig.sdwanMembers || []).length > 0,
+ vdom: fortiConfig.hasVdom || false,
+ vdomList: fortiConfig.vdomList || [],
+ selectedVdom: fortiConfig.selectedVdom || null,
+ routes: (fortiConfig.fullRoutes || fortiConfig.staticRoutes || []).length,
+ bgp: fortiConfig.hasBgp || false,
+ ospf: fortiConfig.hasOspf || false,
+ nonDefaultVrf: fortiConfig.hasNonDefaultVrf || false,
+ existingPolicies: (fortiConfig.existingPolicies || []).length,
+ code: 'CONFIG_TELEMETRY_ASSOCIATED',
+ hostname: normalizeConfigIdentity(fortiConfig).hostname,
+ telemetryDeviceName: session?.telemetryAssociation?.telemetryDeviceName
+ || session?.pendingFortiConfig?.telemetryDeviceName || null,
+ associationStatus: 'associated',
+ telemetryAssociation: session?.telemetryAssociation || null,
+ };
+}
+
+function clearFortiConfigAssociation(session) {
+ session.fortiConfig = null;
+ session.fortiConfigRawText = null;
+ session.fortiConfigContextId = null;
+ session.telemetryAssociation = null;
+ session.pendingFortiConfig = null;
+ session.policyMap = null;
+ setFortiConfig(session.id, null);
+ setFortiConfigRawText(session.id, null);
+ setFortiConfigContextId(session.id, null);
+ setTelemetryAssociation(session.id, null);
+ setPendingFortiConfig(session.id, null);
+}
+
+function stagePendingFortiConfig(session, fortiConfig, rawText, configContextId, decision) {
+ const pending = {
+ id: crypto.randomBytes(16).toString('hex'),
+ fortiConfig,
+ rawText,
+ configContextId,
+ telemetryDeviceName: decision.telemetryDeviceName || null,
+ };
+ session.pendingFortiConfig = pending;
+ setPendingFortiConfig(session.id, pending);
+ return pending;
+}
+
+function commitFortiConfig(session, fortiConfig, rawText, configContextId, telemetryAssociation = null) {
+ const sourceFlows = session.originalFlows || session.data?.flows || [];
+ if (!session.originalFlows && Array.isArray(session.data?.flows)) session.originalFlows = session.data.flows;
+ session.fortiConfig = fortiConfig;
+ session.fortiConfigRawText = rawText || null;
+ session.fortiConfigContextId = configContextId || telemetryAssociation?.configContextId || null;
+ session.telemetryAssociation = telemetryAssociation || null;
+ session.pendingFortiConfig = null;
+ setFortiConfig(session.id, fortiConfig);
+ setFortiConfigRawText(session.id, rawText || null);
+ setFortiConfigContextId(session.id, session.fortiConfigContextId);
+ setTelemetryAssociation(session.id, telemetryAssociation || null);
+ setPendingFortiConfig(session.id, null);
+
+ if (sourceFlows.length > 0) {
+ const knownSubnets = extractKnownSubnets(fortiConfig);
+ const scopedFlows = flowsForFortiConfig(sourceFlows, fortiConfig, telemetryAssociation);
+ const shouldRebuild = knownSubnets.length > 0
+ || scopedFlows.length !== sourceFlows.length
+ || !session.data?.flows;
+ if (shouldRebuild) {
+ const meta = session.data?.meta;
+ const newAnalysis = buildAnalysis(scopedFlows, knownSubnets);
+ newAnalysis.meta = meta;
+ session.data = newAnalysis;
+ setSessionData(session.id, newAnalysis, { originalFlows: sourceFlows });
+ } else {
+ setSessionData(session.id, session.data, { originalFlows: sourceFlows });
+ }
+ }
+
+ const policyMap = new Map();
+ for (const pol of fortiConfig.existingPolicies || []) {
+ policyMap.set(String(pol.policyid), pol);
+ }
+ session.policyMap = policyMap;
+ return buildFortiConfigSummary(fortiConfig, session);
+}
+
+function associationValidationForConfig(session, fortiConfig, configContextId, telemetryDeviceName = null) {
+ const sourceFlows = session.originalFlows || session.data?.flows || [];
+ const scopedFlows = flowsForFortiConfig(sourceFlows, fortiConfig, telemetryDeviceName
+ ? { telemetryDeviceName }
+ : null);
+ return evaluateTelemetryConfigAssociation(
+ scopedFlows,
+ fortiConfig,
+ {
+ telemetryContextId: session.telemetryContextId,
+ configContextId,
+ telemetryDeviceName,
+ },
+ null,
+ );
+}
+
+function sendPendingAssociation(res, decision, pending = null) {
+ const validation = {
+ association: decision,
+ warnings: decision.validation?.warnings || [],
+ };
+ return sendTelemetryAssociationRequired(res, validation, pending);
+}
+
function augmentPreflightEvidence(result, sessionData, fortiConfig, policies) {
const deploymentBlockers = getCaptureDeploymentBlockers(sessionData, fortiConfig);
const extraIssues = [];
@@ -1404,10 +1623,14 @@ app.get('/api/export/workspace', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="fortiflow_workspace_${tsNow()}.ffws"`);
res.json({
- _ffws: 2,
- exportedAt: new Date().toISOString(),
- data: exportData,
- fortiConfig: s.fortiConfig || null,
+ _ffws: 2,
+ exportedAt: new Date().toISOString(),
+ data: exportData,
+ originalFlows: s.originalFlows || exportData.flows || [],
+ telemetryContextId: s.telemetryContextId || null,
+ telemetryAssociation: s.telemetryAssociation || null,
+ fortiConfigContextId: s.fortiConfigContextId || s.telemetryAssociation?.configContextId || null,
+ fortiConfig: s.fortiConfig || null,
});
});
@@ -1418,7 +1641,7 @@ app.post('/api/import/workspace', express.raw({ type: ['application/octet-stream
// Détecte gzip par magic bytes (1f 8b) ou Content-Type
const buf = req.body;
let jsonText;
- if (buf[0] === 0x1f && buf[1] === 0x8b) {
+ if (Buffer.isBuffer(buf) && buf[0] === 0x1f && buf[1] === 0x8b) {
jsonText = await new Promise((resolve, reject) =>
zlib.gunzip(
buf,
@@ -1426,19 +1649,43 @@ app.post('/api/import/workspace', express.raw({ type: ['application/octet-stream
(err, out) => err ? reject(err) : resolve(out.toString('utf8'))
)
);
- } else {
+ } else if (Buffer.isBuffer(buf)) {
jsonText = buf.toString('utf8');
+ } else if (typeof buf === 'string') {
+ jsonText = buf;
+ } else {
+ jsonText = JSON.stringify(buf || {});
}
const body = validateWorkspaceBody(parseWorkspaceJson(jsonText));
const importedData = stripLegacyNetworkDecisions(body.data);
+ const importedFlows = body.originalFlows || importedData.flows || [];
+ const telemetryContextId = body.telemetryContextId || crypto.randomBytes(16).toString('hex');
+ const configContextId = body.fortiConfigContextId || body.telemetryAssociation?.configContextId || null;
+ if (body.fortiConfig) {
+ const consistency = validateWorkspaceConfigTelemetry(
+ { flows: importedFlows },
+ body.fortiConfig,
+ body.telemetryAssociation || null,
+ { telemetryContextId, configContextId },
+ );
+ if (!consistency.ok) {
+ if (consistency.associationRequired) return sendTelemetryAssociationRequired(res, consistency);
+ return sendConfigTelemetryMismatch(res, consistency);
+ }
+ }
+ const id = createSession({ telemetryContextId });
+ const session = getSession(id);
+ session.originalFlows = importedFlows;
+ setSessionData(id, importedData, { originalFlows: importedFlows });
if (body.fortiConfig) {
- const consistency = validateWorkspaceConfigTelemetry(importedData, body.fortiConfig);
- if (!consistency.ok) return sendConfigTelemetryMismatch(res, consistency);
+ session.fortiConfigRawText = null;
+ session.fortiConfigContextId = configContextId;
+ session.telemetryAssociation = body.telemetryAssociation || null;
+ setFortiConfig(id, body.fortiConfig);
+ setFortiConfigContextId(id, configContextId);
+ setTelemetryAssociation(id, session.telemetryAssociation);
}
- const id = createSession();
- setSessionData(id, importedData);
- if (body.fortiConfig) setFortiConfig(id, body.fortiConfig);
- res.json({ sessionId: id });
+ res.json({ sessionId: id, telemetryAssociation: session.telemetryAssociation || null });
} catch (err) {
res.status(400).json({ error: 'Fichier corrompu ou illisible : ' + err.message });
}
@@ -1471,10 +1718,14 @@ app.post('/api/workspaces', express.json({ limit: '10kb' }), async (req, res) =>
}
const payload = JSON.stringify({
- _ffws: 2,
- exportedAt: new Date().toISOString(),
- data: exportData,
- fortiConfig: s.fortiConfig || null,
+ _ffws: 2,
+ exportedAt: new Date().toISOString(),
+ data: exportData,
+ originalFlows: s.originalFlows || exportData.flows || [],
+ telemetryContextId: s.telemetryContextId || null,
+ telemetryAssociation: s.telemetryAssociation || null,
+ fortiConfigContextId: s.fortiConfigContextId || s.telemetryAssociation?.configContextId || null,
+ fortiConfig: s.fortiConfig || null,
});
const id = require('crypto').randomBytes(8).toString('hex');
@@ -1507,14 +1758,40 @@ app.get('/api/workspaces/:id', async (req, res) => {
);
const body = validateWorkspaceBody(parseWorkspaceJson(json));
const importedData = stripLegacyNetworkDecisions(body.data);
+ const importedFlows = body.originalFlows || importedData.flows || [];
+ const telemetryContextId = body.telemetryContextId || crypto.randomBytes(16).toString('hex');
+ const configContextId = body.fortiConfigContextId || body.telemetryAssociation?.configContextId || null;
if (body.fortiConfig) {
- const consistency = validateWorkspaceConfigTelemetry(importedData, body.fortiConfig);
- if (!consistency.ok) return sendConfigTelemetryMismatch(res, consistency);
+ const consistency = validateWorkspaceConfigTelemetry(
+ { flows: importedFlows },
+ body.fortiConfig,
+ body.telemetryAssociation || null,
+ { telemetryContextId, configContextId },
+ );
+ if (!consistency.ok) {
+ if (consistency.associationRequired) return sendTelemetryAssociationRequired(res, consistency);
+ return sendConfigTelemetryMismatch(res, consistency);
+ }
+ }
+ const newId = createSession({ telemetryContextId });
+ const session = getSession(newId);
+ session.originalFlows = importedFlows;
+ setSessionData(newId, importedData, { originalFlows: importedFlows });
+ if (body.fortiConfig) {
+ session.fortiConfigRawText = null;
+ session.fortiConfigContextId = configContextId;
+ session.telemetryAssociation = body.telemetryAssociation || null;
+ setFortiConfig(newId, body.fortiConfig);
+ setFortiConfigContextId(newId, configContextId);
+ setTelemetryAssociation(newId, session.telemetryAssociation);
}
- const newId = createSession();
- setSessionData(newId, importedData);
- if (body.fortiConfig) setFortiConfig(newId, body.fortiConfig);
- res.json({ sessionId: newId, name: entry.name, hasFortiConfig: !!body.fortiConfig });
+ res.json({
+ sessionId: newId,
+ name: entry.name,
+ hasFortiConfig: !!body.fortiConfig,
+ fortiConfig: body.fortiConfig ? buildFortiConfigSummary(body.fortiConfig, session) : null,
+ telemetryAssociation: session.telemetryAssociation || null,
+ });
} catch (err) {
res.status(500).json({ error: err.message });
}
@@ -1789,7 +2066,7 @@ app.get('/api/denied-flows', (req, res) => {
// ─── Deploy routes ────────────────────────────────────────────────────────────
-// POST /api/deploy/config-upload — parse a FortiGate .conf and store in session
+// POST /api/deploy/config-upload — parse a FortiGate .conf and associate it to telemetry
app.post('/api/deploy/config-upload', upload.single('conffile'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu' });
@@ -1804,52 +2081,20 @@ app.post('/api/deploy/config-upload', upload.single('conffile'), async (req, res
initialConfig.vdomList,
);
const fortiConfig = telemetryVdom ? parseFortiConfig(text, telemetryVdom) : initialConfig;
- const consistency = assertConfigTelemetry(s, fortiConfig);
- if (!consistency.ok) return sendConfigTelemetryMismatch(res, consistency);
-
- s.fortiConfig = fortiConfig;
- s.fortiConfigRawText = text;
- setFortiConfig(s.id, fortiConfig);
-
- // Re-analyze with real CIDR subnets from the FortiGate config (before freeing flows).
- // This replaces the /24 fallback grouping with actual subnet boundaries.
- if (s.data?.flows?.length > 0) {
- const knownSubnets = extractKnownSubnets(fortiConfig);
- if (knownSubnets.length > 0) {
- const meta = s.data.meta;
- if (!s.originalFlows) s.originalFlows = s.data.flows;
- const scopedFlows = flowsForFortiConfig(s.originalFlows, fortiConfig);
- const newAnalysis = buildAnalysis(scopedFlows, knownSubnets);
- newAnalysis.meta = meta;
- s.data = newAnalysis;
- setSessionData(s.id, newAnalysis);
- }
- }
+ const configContextId = newConfigContextId();
- // Build a fast policyid → policy lookup (keyed as string for log compatibility)
- const policyMap = new Map();
- for (const pol of fortiConfig.existingPolicies || []) {
- policyMap.set(String(pol.policyid), pol);
+ // Toute nouvelle configuration invalide l’association précédente avant le gate.
+ clearFortiConfigAssociation(s);
+ const decision = associationValidationForConfig(s, fortiConfig, configContextId);
+ if (decision.status === 'contradiction') {
+ return sendConfigTelemetryMismatch(res, decision.validation);
+ }
+ if (decision.status === 'confirmation_required' || decision.status === 'selection_required') {
+ const pending = stagePendingFortiConfig(s, fortiConfig, text, configContextId, decision);
+ return sendPendingAssociation(res, decision, pending);
}
- s.policyMap = policyMap;
- res.json({
- addresses: Object.keys(fortiConfig.addresses).length,
- addrGroups: Object.keys(fortiConfig.addressGroups || {}).length,
- services: Object.keys(fortiConfig.customServices).length,
- serviceGroups: Object.keys(fortiConfig.serviceGroups || {}).length,
- interfaces: Object.keys(fortiConfig.interfaces).length,
- zones: Object.keys(fortiConfig.zones).length,
- sdwan: fortiConfig.sdwanMembers.length > 0,
- vdom: fortiConfig.hasVdom || false,
- vdomList: fortiConfig.vdomList || [],
- selectedVdom: fortiConfig.selectedVdom || null,
- routes: (fortiConfig.fullRoutes || fortiConfig.staticRoutes).length,
- bgp: fortiConfig.hasBgp || false,
- ospf: fortiConfig.hasOspf || false,
- nonDefaultVrf: fortiConfig.hasNonDefaultVrf || false,
- existingPolicies: (fortiConfig.existingPolicies || []).length,
- });
+ return res.json(commitFortiConfig(s, fortiConfig, text, configContextId, null));
} catch (err) {
res.status(500).json({ error: err.message });
} finally {
@@ -1857,6 +2102,98 @@ app.post('/api/deploy/config-upload', upload.single('conffile'), async (req, res
}
});
+// POST /api/deploy/config-association — select, confirm or refuse the pending config
+app.post('/api/deploy/config-association', express.json(), (req, res) => {
+ const s = requireSession(req, res);
+ if (!s) return;
+ const pending = s.pendingFortiConfig;
+ if (!pending) {
+ return res.status(409).json({
+ error: 'Aucune configuration en attente d’association.',
+ code: 'CONFIG_TELEMETRY_ASSOCIATION_NOT_PENDING',
+ });
+ }
+
+ const { action, telemetryDeviceName, pendingConfigId } = req.body || {};
+ if (!pendingConfigId || pendingConfigId !== pending.id) {
+ return res.status(409).json({
+ error: 'La configuration en attente n’est plus active.',
+ code: 'CONFIG_TELEMETRY_ASSOCIATION_STALE',
+ });
+ }
+ if (action === 'refuse') {
+ clearFortiConfigAssociation(s);
+ return res.json({
+ ok: false,
+ status: 'unassociated',
+ code: 'CONFIG_TELEMETRY_ASSOCIATION_REFUSED',
+ associationStatus: 'unassociated',
+ association: null,
+ });
+ }
+ const names = new Set((s.originalFlows || s.data?.flows || [])
+ .map(telemetryFlowDeviceName).filter(Boolean));
+ const selected = String(telemetryDeviceName || pending.telemetryDeviceName || '').trim();
+ if (!selected || !names.has(selected)) {
+ return res.status(400).json({
+ error: 'L’équipement télémétrie sélectionné est inconnu.',
+ code: 'CONFIG_TELEMETRY_DEVICE_SELECTION_INVALID',
+ association: { telemetryDeviceNames: [...names].sort() },
+ });
+ }
+
+ if (action !== 'select' && action !== 'confirm') {
+ return res.status(400).json({ error: 'action requis (select|confirm|refuse)' });
+ }
+
+ const decision = associationValidationForConfig(
+ s,
+ pending.fortiConfig,
+ pending.configContextId,
+ selected,
+ );
+ if (decision.status === 'contradiction') {
+ return sendConfigTelemetryMismatch(res, decision.validation);
+ }
+ const hostname = normalizeConfigIdentity(pending.fortiConfig).hostname;
+ const exactName = Boolean(hostname && selected === hostname);
+ const nameResolved = !hostname || exactName;
+ if (action === 'select' && !nameResolved) {
+ return sendPendingAssociation(res, decision, pending);
+ }
+ if (action === 'confirm' && nameResolved) {
+ return res.status(400).json({
+ error: 'La correspondance exacte ne nécessite pas de confirmation.',
+ code: 'CONFIG_TELEMETRY_ASSOCIATION_CONFIRMATION_UNEXPECTED',
+ });
+ }
+ if (action === 'confirm' && decision.status !== 'confirmation_required') {
+ return sendPendingAssociation(res, decision, pending);
+ }
+
+ const telemetryAssociation = nameResolved
+ ? createSelectedTelemetryAssociation({
+ telemetryDeviceName: selected,
+ configHostname: hostname,
+ telemetryContextId: s.telemetryContextId,
+ configContextId: pending.configContextId,
+ })
+ : createConfirmedTelemetryAssociation({
+ telemetryDeviceName: selected,
+ configHostname: hostname,
+ telemetryContextId: s.telemetryContextId,
+ configContextId: pending.configContextId,
+ });
+ const summary = commitFortiConfig(
+ s,
+ pending.fortiConfig,
+ pending.rawText,
+ pending.configContextId,
+ telemetryAssociation,
+ );
+ return res.json(summary);
+});
+
// POST /api/deploy/config-vdom — re-parse the stored config for a different VDOM
app.post('/api/deploy/config-vdom', express.json(), (req, res) => {
const s = requireSession(req, res);
@@ -1867,49 +2204,17 @@ app.post('/api/deploy/config-vdom', express.json(), (req, res) => {
if (!vdom) return res.status(400).json({ error: 'vdom requis' });
try {
- const fortiConfig = parseFortiConfig(s.fortiConfigRawText, vdom);
- const consistency = assertConfigTelemetry(s, fortiConfig);
- if (!consistency.ok) return sendConfigTelemetryMismatch(res, consistency);
-
- s.fortiConfig = fortiConfig;
- setFortiConfig(s.id, fortiConfig);
-
- // Un changement de VDOM doit aussi recalculer les suggestions et exclure
- // les logs portant explicitement un autre VDOM.
- const sourceFlows = s.originalFlows || s.data?.flows || [];
- if (sourceFlows.length > 0) {
- const meta = s.data?.meta;
- const knownSubnets = extractKnownSubnets(fortiConfig);
- const scopedFlows = flowsForFortiConfig(sourceFlows, fortiConfig);
- const newAnalysis = buildAnalysis(scopedFlows, knownSubnets);
- newAnalysis.meta = meta;
- s.data = newAnalysis;
- setSessionData(s.id, newAnalysis);
- }
-
- const policyMap = new Map();
- for (const pol of fortiConfig.existingPolicies || []) {
- policyMap.set(String(pol.policyid), pol);
+ const rawText = s.fortiConfigRawText;
+ const fortiConfig = parseFortiConfig(rawText, vdom);
+ const configContextId = newConfigContextId();
+ clearFortiConfigAssociation(s);
+ const decision = associationValidationForConfig(s, fortiConfig, configContextId);
+ if (decision.status === 'contradiction') return sendConfigTelemetryMismatch(res, decision.validation);
+ if (decision.status === 'confirmation_required' || decision.status === 'selection_required') {
+ const pending = stagePendingFortiConfig(s, fortiConfig, rawText, configContextId, decision);
+ return sendPendingAssociation(res, decision, pending);
}
- s.policyMap = policyMap;
-
- res.json({
- addresses: Object.keys(fortiConfig.addresses).length,
- addrGroups: Object.keys(fortiConfig.addressGroups || {}).length,
- services: Object.keys(fortiConfig.customServices).length,
- serviceGroups: Object.keys(fortiConfig.serviceGroups || {}).length,
- interfaces: Object.keys(fortiConfig.interfaces).length,
- zones: Object.keys(fortiConfig.zones).length,
- sdwan: fortiConfig.sdwanMembers.length > 0,
- vdom: fortiConfig.hasVdom || false,
- vdomList: fortiConfig.vdomList || [],
- selectedVdom: fortiConfig.selectedVdom || null,
- routes: (fortiConfig.fullRoutes || fortiConfig.staticRoutes).length,
- bgp: fortiConfig.hasBgp || false,
- ospf: fortiConfig.hasOspf || false,
- nonDefaultVrf: fortiConfig.hasNonDefaultVrf || false,
- existingPolicies: (fortiConfig.existingPolicies || []).length,
- });
+ return res.json(commitFortiConfig(s, fortiConfig, rawText, configContextId, null));
} catch (err) {
res.status(500).json({ error: err.message });
}
diff --git a/app/web/test/policy-engine-v2-ui.test.js b/app/web/test/policy-engine-v2-ui.test.js
index 9a89a8d..f32c8b3 100644
--- a/app/web/test/policy-engine-v2-ui.test.js
+++ b/app/web/test/policy-engine-v2-ui.test.js
@@ -73,3 +73,20 @@ test('un mismatch de cohérence reste bloquant avant l’étape Règles', () =>
assert.ok(appSource.includes('addressSelectionMismatch'));
assert.ok(appSource.includes('Aucune règle ne peut être construite'));
});
+
+test('le mismatch de nom reste dans le workflow Déployer avec sélection et confirmation françaises', () => {
+ for (const label of [
+ 'Nom d’équipement différent',
+ 'Nom télémétrie',
+ 'Hostname configuration',
+ 'Impossible de confirmer automatiquement',
+ 'Confirmer qu’il s’agit du même FortiGate',
+ 'Choisir une autre configuration',
+ 'Quel équipement détecté correspond à cette configuration',
+ ]) {
+ assert.ok(appSource.includes(label), `libellé absent: ${label}`);
+ }
+ assert.match(appSource, /api\/deploy\/config-association/);
+ assert.ok(appSource.includes('CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED'));
+ assert.match(styleSource, /\.telemetry-association/);
+});
diff --git a/app/web/test/server-dependencies.test.js b/app/web/test/server-dependencies.test.js
index 8c58e32..224afed 100644
--- a/app/web/test/server-dependencies.test.js
+++ b/app/web/test/server-dependencies.test.js
@@ -61,7 +61,7 @@ test('le chargement de configuration passe le gate de cohérence avant toute mut
assert.match(source, /\.\/lib\/config-consistency/);
assert.match(source, /CONFIG_TELEMETRY_MISMATCH/);
const gate = source.indexOf('validateConfigTelemetryConsistency');
- const mutation = source.indexOf('s.fortiConfig = fortiConfig');
+ const mutation = source.indexOf('session.fortiConfig = fortiConfig');
assert.ok(gate >= 0, 'le gate de cohérence doit être appelé');
assert.ok(mutation >= 0, 'la mutation de session doit rester identifiable');
assert.ok(gate < mutation, 'la configuration ne doit pas muter avant la validation');
@@ -138,6 +138,16 @@ test('l’import workspace ignore networkDecisions avant la restauration de sess
assert.ok(deleteIndex >= 0 && deleteIndex < importStart);
});
+test('le serveur expose un état de confirmation télémétrie/configuration persistant', () => {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
+ assert.match(source, /telemetry-association/);
+ assert.match(source, /CONFIG_TELEMETRY_ASSOCIATION_REQUIRED/);
+ assert.match(source, /app\.post\(['"]\/api\/deploy\/config-association['"]/);
+ assert.match(source, /telemetryAssociation/);
+ assert.match(source, /telemetryContextId/);
+ assert.match(source, /pendingFortiConfig/);
+});
+
test(
'le reverse proxy écrase X-Forwarded-For avec l’adresse TCP réelle',
{
diff --git a/app/web/test/telemetry-association-api.test.js b/app/web/test/telemetry-association-api.test.js
new file mode 100644
index 0000000..a17d4c7
--- /dev/null
+++ b/app/web/test/telemetry-association-api.test.js
@@ -0,0 +1,308 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const net = require('node:net');
+const fs = require('node:fs');
+const os = require('node:os');
+const { spawn } = require('node:child_process');
+const path = require('node:path');
+
+let child;
+let uploadDir;
+let baseUrl;
+
+function freePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const port = server.address().port;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+function request(pathname, { method = 'GET', headers = {}, body = null } = {}) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(pathname, baseUrl);
+ const req = http.request(url, { method, headers }, res => {
+ const chunks = [];
+ res.on('data', chunk => chunks.push(chunk));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch {}
+ resolve({ status: res.statusCode, headers: res.headers, text, json });
+ });
+ });
+ req.once('error', reject);
+ if (body) req.write(body);
+ req.end();
+ });
+}
+
+function multipart(field, filename, content) {
+ const boundary = `----FortiFlowTest${Date.now()}${Math.random().toString(16).slice(2)}`;
+ const body = Buffer.from([
+ `--${boundary}\r\n`,
+ `Content-Disposition: form-data; name="${field}"; filename="${filename}"\r\n`,
+ 'Content-Type: text/plain\r\n\r\n',
+ content,
+ `\r\n--${boundary}--\r\n`,
+ ].join(''), 'utf8');
+ return {
+ body,
+ headers: {
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
+ 'Content-Length': body.length,
+ },
+ };
+}
+
+async function uploadLog(devnames = ['FW-COM']) {
+ const lines = devnames.map((devname, index) => [
+ 'date=2026-08-23 time=12:00:0' + index,
+ 'type=traffic subtype=forward',
+ `devname=${devname} devid=FGT-AVR-01 vd=root`,
+ `srcip=10.250.16.${10 + index} dstip=10.251.16.20`,
+ 'proto=6 dstport=443 action=accept service=HTTPS',
+ 'srcintf=lan dstintf=servers policyid=1 sentbyte=10 rcvdbyte=10',
+ ].join(' ')).join('\n');
+ const part = multipart('logfile', `telemetry-${Date.now()}-${Math.random().toString(16).slice(2)}.log`, lines);
+ const uploaded = await request('/api/upload', { method: 'POST', headers: part.headers, body: part.body });
+ assert.equal(uploaded.status, 200, uploaded.text);
+ const sessionId = uploaded.json.sessionId;
+ for (let i = 0; i < 100; i++) {
+ const stats = await request(`/api/stats?session=${sessionId}`);
+ if (stats.status === 200) return sessionId;
+ await new Promise(resolve => setTimeout(resolve, 25));
+ }
+ throw new Error('analyse télémétrie non terminée');
+}
+
+const configText = hostname => `
+config system global
+ set hostname "${hostname}"
+end
+config system interface
+ edit "lan"
+ set ip 10.250.16.1 255.255.254.0
+ set role lan
+ next
+ edit "servers"
+ set ip 10.251.16.1 255.255.255.0
+ set role lan
+ next
+end
+`;
+
+async function uploadConfig(sessionId, hostname, text = configText(hostname)) {
+ const part = multipart('conffile', `${hostname}-${Date.now()}-${Math.random().toString(16).slice(2)}.conf`, text);
+ return request(`/api/deploy/config-upload?session=${sessionId}`, {
+ method: 'POST', headers: part.headers, body: part.body,
+ });
+}
+
+test.beforeEach(async () => {
+ const port = await freePort();
+ uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fortiflow-association-'));
+ baseUrl = `http://127.0.0.1:${port}`;
+ child = spawn(process.execPath, ['server.js'], {
+ cwd: path.join(__dirname, '..'),
+ env: {
+ ...process.env,
+ PORT: String(port),
+ FORTIFLOW_BIND_ADDRESS: '127.0.0.1',
+ FORTIFLOW_UPLOAD_DIR: uploadDir,
+ },
+ stdio: 'ignore',
+ });
+ for (let i = 0; i < 100; i++) {
+ if (child.exitCode !== null) throw new Error(`serveur FortiFlow arrêté (${child.exitCode})`);
+ try {
+ const health = await request('/api/health');
+ if (health.status === 200) return;
+ } catch {}
+ await new Promise(resolve => setTimeout(resolve, 25));
+ }
+ throw new Error('serveur FortiFlow non démarré');
+});
+
+test.afterEach(async () => {
+ if (child && child.exitCode === null) {
+ child.kill('SIGTERM');
+ await new Promise(resolve => child.once('exit', resolve));
+ }
+ if (uploadDir) fs.rmSync(uploadDir, { recursive: true, force: true });
+});
+
+test('l’API demande puis enregistre une confirmation de nom sans perdre les garde-fous', async () => {
+ const sessionId = await uploadLog(['FW-COM']);
+ const mismatch = await uploadConfig(sessionId, 'FW-AVR-01');
+ assert.equal(mismatch.status, 409, mismatch.text);
+ assert.equal(mismatch.json.code, 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED');
+ assert.equal(mismatch.json.association.telemetryDeviceName, 'FW-COM');
+ assert.equal(mismatch.json.association.configHostname, 'FW-AVR-01');
+
+ const staleWithoutContext = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'confirm', telemetryDeviceName: 'FW-COM' }),
+ });
+ assert.equal(staleWithoutContext.status, 409, staleWithoutContext.text);
+ assert.equal(staleWithoutContext.json.code, 'CONFIG_TELEMETRY_ASSOCIATION_STALE');
+
+ const confirmed = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'confirm',
+ telemetryDeviceName: 'FW-COM',
+ pendingConfigId: mismatch.json.pendingConfigId,
+ }),
+ });
+ assert.equal(confirmed.status, 200, confirmed.text);
+ assert.equal(confirmed.json.code, 'CONFIG_TELEMETRY_ASSOCIATED');
+ assert.equal(confirmed.json.telemetryAssociation.confirmedByUser, true);
+ assert.equal(confirmed.json.telemetryAssociation.telemetryDeviceName, 'FW-COM');
+ assert.equal(confirmed.json.telemetryAssociation.configHostname, 'FW-AVR-01');
+ assert.equal(confirmed.json.associationStatus, 'associated');
+
+ const usable = await request(`/api/deploy/interfaces?session=${sessionId}`);
+ assert.equal(usable.status, 200, usable.text);
+});
+
+test('une confirmation survit à l’export/import et à la recharge d’un workspace', async () => {
+ const sessionId = await uploadLog(['FW-COM']);
+ const mismatch = await uploadConfig(sessionId, 'FW-AVR-01');
+ const confirmed = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'confirm', telemetryDeviceName: 'FW-COM', pendingConfigId: mismatch.json.pendingConfigId,
+ }),
+ });
+ assert.equal(confirmed.status, 200, confirmed.text);
+
+ const exported = await request(`/api/export/workspace?session=${sessionId}`);
+ assert.equal(exported.status, 200, exported.text);
+ assert.equal(exported.json.telemetryAssociation.confirmedByUser, true);
+ assert.equal(exported.json.telemetryAssociation.telemetryDeviceName, 'FW-COM');
+ assert.equal(exported.json.telemetryAssociation.configHostname, 'FW-AVR-01');
+ assert.equal(typeof exported.json.telemetryContextId, 'string');
+ assert.equal(
+ Object.hasOwn(exported.json, 'fortiConfigRawText'),
+ false,
+ 'le workspace ne doit pas embarquer le texte brut potentiellement sensible de la configuration',
+ );
+
+ const imported = await request('/api/import/workspace', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(exported.text) },
+ body: exported.text,
+ });
+ assert.equal(imported.status, 200, imported.text);
+ const restoredId = imported.json.sessionId;
+ const restored = await request(`/api/deploy/interfaces?session=${restoredId}`);
+ assert.equal(restored.status, 200, restored.text);
+ const restoredExport = await request(`/api/export/workspace?session=${restoredId}`);
+ assert.equal(restoredExport.status, 200, restoredExport.text);
+ assert.deepEqual(restoredExport.json.telemetryAssociation, exported.json.telemetryAssociation);
+ assert.equal(restoredExport.json.telemetryContextId, exported.json.telemetryContextId);
+});
+
+test('la télémétrie multi-équipement impose une sélection puis une confirmation si le nom diffère', async () => {
+ const sessionId = await uploadLog(['FW-A', 'FW-B']);
+ const selection = await uploadConfig(sessionId, 'FW-B-01');
+ assert.equal(selection.status, 409, selection.text);
+ assert.equal(selection.json.code, 'CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED');
+ assert.deepEqual(selection.json.association.telemetryDeviceNames, ['FW-A', 'FW-B']);
+ assert.equal(selection.json.association.telemetryDeviceName, null);
+
+ const selected = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'select',
+ telemetryDeviceName: 'FW-B',
+ pendingConfigId: selection.json.pendingConfigId,
+ }),
+ });
+ assert.equal(selected.status, 409, selected.text);
+ assert.equal(selected.json.code, 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED');
+ assert.equal(selected.json.association.telemetryDeviceName, 'FW-B');
+ assert.equal(selected.json.association.configHostname, 'FW-B-01');
+
+ const confirmed = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'confirm',
+ telemetryDeviceName: 'FW-B',
+ pendingConfigId: selection.json.pendingConfigId,
+ }),
+ });
+ assert.equal(confirmed.status, 200, confirmed.text);
+ assert.equal(confirmed.json.telemetryAssociation.confirmedByUser, true);
+ assert.equal(confirmed.json.telemetryAssociation.telemetryDeviceName, 'FW-B');
+ const scopedStats = await request(`/api/stats?session=${sessionId}`);
+ assert.equal(scopedStats.status, 200, scopedStats.text);
+ assert.equal(scopedStats.json.stats.uniqueFlows, 1);
+});
+
+test('une confirmation survit aussi au workspace nommé de l’historique', async () => {
+ const sessionId = await uploadLog(['FW-COM']);
+ const mismatch = await uploadConfig(sessionId, 'FW-AVR-01');
+ const confirmed = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'confirm', telemetryDeviceName: 'FW-COM', pendingConfigId: mismatch.json.pendingConfigId }),
+ });
+ assert.equal(confirmed.status, 200, confirmed.text);
+ const saved = await request(`/api/workspaces?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: `association-${Date.now()}-${Math.random().toString(16).slice(2)}` }),
+ });
+ assert.equal(saved.status, 200, saved.text);
+ const loaded = await request(`/api/workspaces/${saved.json.id}`);
+ assert.equal(loaded.status, 200, loaded.text);
+ assert.equal(loaded.json.telemetryAssociation.confirmedByUser, true);
+ assert.equal(loaded.json.fortiConfig.telemetryAssociation.confirmedByUser, true);
+ assert.equal((await request(`/api/deploy/interfaces?session=${loaded.json.sessionId}`)).status, 200);
+ const removed = await request(`/api/workspaces/${saved.json.id}`, { method: 'DELETE' });
+ assert.equal(removed.status, 200, removed.text);
+});
+
+test('le changement de configuration et une nouvelle session ne réutilisent jamais une association', async () => {
+ const sessionId = await uploadLog(['FW-COM']);
+ const mismatch = await uploadConfig(sessionId, 'FW-AVR-01');
+ const confirmed = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'confirm', telemetryDeviceName: 'FW-COM', pendingConfigId: mismatch.json.pendingConfigId }),
+ });
+ assert.equal(confirmed.status, 200, confirmed.text);
+
+ const changedConfig = await uploadConfig(sessionId, 'FW-OTHER');
+ assert.equal(changedConfig.status, 409, changedConfig.text);
+ assert.equal(changedConfig.json.code, 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED');
+ assert.notEqual(changedConfig.json.pendingConfigId, mismatch.json.pendingConfigId);
+ assert.equal((await request(`/api/deploy/interfaces?session=${sessionId}`)).status, 404);
+
+ const refused = await request(`/api/deploy/config-association?session=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'refuse', pendingConfigId: changedConfig.json.pendingConfigId }),
+ });
+ assert.equal(refused.status, 200, refused.text);
+ assert.equal(refused.json.code, 'CONFIG_TELEMETRY_ASSOCIATION_REFUSED');
+ assert.equal(refused.json.status, 'unassociated');
+
+ const newSessionId = await uploadLog(['FW-COM']);
+ const newSessionConfig = await uploadConfig(newSessionId, 'FW-AVR-01');
+ assert.equal(newSessionConfig.status, 409, newSessionConfig.text);
+ assert.equal(newSessionConfig.json.code, 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED');
+});
diff --git a/app/web/test/telemetry-association-persistence.test.js b/app/web/test/telemetry-association-persistence.test.js
new file mode 100644
index 0000000..e8a63b1
--- /dev/null
+++ b/app/web/test/telemetry-association-persistence.test.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+
+const store = require('../lib/store');
+
+function cleanup(id) {
+ store.deleteSession(id);
+}
+
+test('la session conserve une association confirmée et son contexte dans le cache serveur', () => {
+ assert.equal(typeof store.setTelemetryAssociation, 'function');
+ assert.equal(typeof store.setTelemetryContextId, 'function');
+ const id = store.createSession({ telemetryContextId: 'telemetry-1' });
+ try {
+ store.setSessionData(id, { flows: [], stats: {}, meta: {} });
+ store.setTelemetryAssociation(id, {
+ telemetryDeviceName: 'FW-COM',
+ configHostname: 'FW-AVR-01',
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ confirmedByUser: true,
+ confirmedAt: '2026-08-23T12:00:00.000Z',
+ });
+ const session = store.getSession(id);
+ assert.equal(session.telemetryContextId, 'telemetry-1');
+ assert.equal(session.telemetryAssociation.confirmedByUser, true);
+ assert.equal(session.telemetryAssociation.telemetryDeviceName, 'FW-COM');
+ } finally {
+ cleanup(id);
+ }
+});
+
+test('le cache disque recharge l’association et son contexte après un redémarrage', async () => {
+ const id = store.createSession({ telemetryContextId: `telemetry-cache-${Date.now()}` });
+ const association = {
+ telemetryDeviceName: 'FW-COM',
+ configHostname: 'FW-AVR-01',
+ telemetryContextId: store.getSession(id).telemetryContextId,
+ configContextId: 'config-cache-1',
+ confirmedByUser: true,
+ confirmedAt: '2026-08-23T12:00:00.000Z',
+ };
+ try {
+ store.setSessionData(id, { flows: [], stats: {}, meta: {} });
+ store.setTelemetryAssociation(id, association);
+ await new Promise(resolve => setTimeout(resolve, 100));
+ const probe = spawnSync(process.execPath, ['-e', `
+ const store = require(${JSON.stringify(path.join(__dirname, '..', 'lib', 'store'))});
+ const s = store.getSession(${JSON.stringify(id)});
+ if (!s || s.telemetryContextId !== ${JSON.stringify(association.telemetryContextId)} || s.telemetryAssociation?.configContextId !== 'config-cache-1') process.exit(2);
+ `], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
+ assert.equal(probe.status, 0, probe.stderr || probe.stdout);
+ } finally {
+ cleanup(id);
+ }
+});
diff --git a/app/web/test/telemetry-association.test.js b/app/web/test/telemetry-association.test.js
new file mode 100644
index 0000000..b3b4d30
--- /dev/null
+++ b/app/web/test/telemetry-association.test.js
@@ -0,0 +1,215 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+let association = {};
+try { association = require('../lib/telemetry-association'); } catch {}
+
+function config(overrides = {}) {
+ return {
+ identity: {
+ hostname: 'FW-AVR-01',
+ devid: 'FGT-AVR-01',
+ selectedVdom: 'root',
+ vdomList: ['root'],
+ ...overrides.identity,
+ },
+ selectedVdom: 'root',
+ interfaces: {
+ lan: { name: 'lan', cidr: '10.250.16.0/23' },
+ servers: { name: 'servers', cidr: '10.251.16.0/24' },
+ },
+ ...overrides,
+ };
+}
+
+function flow(overrides = {}) {
+ return {
+ devname: 'FW-AVR-01',
+ devid: 'FGT-AVR-01',
+ vdom: 'root',
+ srcip: '10.250.16.10',
+ srcintf: 'lan',
+ dstip: '10.251.16.20',
+ dstintf: 'servers',
+ ...overrides,
+ };
+}
+
+test('un nom identique télémétrie/configuration est associé automatiquement', () => {
+ assert.equal(typeof association.evaluateTelemetryConfigAssociation, 'function');
+ const result = association.evaluateTelemetryConfigAssociation(
+ [flow()],
+ config(),
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-1' },
+ );
+ assert.equal(result.status, 'matched');
+ assert.equal(result.requiresConfirmation, false);
+ assert.equal(result.telemetryDeviceName, 'FW-AVR-01');
+ assert.equal(result.configHostname, 'FW-AVR-01');
+});
+
+test('un nom différent exige une confirmation explicite sans devenir une erreur 422', () => {
+ const result = association.evaluateTelemetryConfigAssociation(
+ [flow({ devname: 'FW-AVR' })],
+ config(),
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-1' },
+ );
+ assert.equal(result.status, 'confirmation_required');
+ assert.equal(result.code, 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED');
+ assert.equal(result.requiresConfirmation, true);
+ assert.equal(result.telemetryDeviceName, 'FW-AVR');
+ assert.equal(result.configHostname, 'FW-AVR-01');
+ assert.deepEqual(result.validation.errors, []);
+});
+
+test('un nom différent sans identifiant technique fort exige quand même la confirmation utilisateur', () => {
+ const result = association.evaluateTelemetryConfigAssociation(
+ [flow({ devname: 'FW-AVR', devid: '' })],
+ { identity: { hostname: 'FW-AVR-01' }, interfaces: {}, zones: {} },
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-minimal' },
+ );
+ assert.equal(result.status, 'confirmation_required');
+ assert.equal(result.code, 'CONFIG_TELEMETRY_ASSOCIATION_REQUIRED');
+ assert.equal(result.telemetryDeviceName, 'FW-AVR');
+ assert.equal(result.configHostname, 'FW-AVR-01');
+});
+
+test('une confirmation utilisateur rend la configuration utilisable dans le même contexte exact', () => {
+ assert.equal(typeof association.createConfirmedTelemetryAssociation, 'function');
+ assert.equal(typeof association.isTelemetryAssociationUsable, 'function');
+ const context = { telemetryContextId: 'telemetry-1', configContextId: 'config-1' };
+ const confirmed = association.createConfirmedTelemetryAssociation({
+ telemetryDeviceName: 'FW-COM',
+ configHostname: 'FW-AVR-01',
+ ...context,
+ confirmedAt: '2026-08-23T12:00:00.000Z',
+ });
+ assert.equal(confirmed.confirmedByUser, true);
+ assert.equal(association.isTelemetryAssociationUsable(confirmed, context), true);
+ const result = association.evaluateTelemetryConfigAssociation(
+ [flow({ devname: 'FW-COM' })],
+ config(),
+ context,
+ confirmed,
+ );
+ assert.equal(result.status, 'matched');
+ assert.equal(result.confirmedByUser, true);
+});
+
+test('un refus désassocie la configuration et toute variation de contexte invalide la confirmation', () => {
+ assert.equal(typeof association.refuseTelemetryConfigAssociation, 'function');
+ const confirmed = association.createConfirmedTelemetryAssociation({
+ telemetryDeviceName: 'FW-COM',
+ configHostname: 'FW-AVR-01',
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ confirmedAt: '2026-08-23T12:00:00.000Z',
+ });
+ const reloaded = JSON.parse(JSON.stringify(confirmed));
+ assert.equal(association.isTelemetryAssociationUsable(reloaded, {
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ }), true);
+ assert.equal(association.isTelemetryAssociationUsable(confirmed, {
+ telemetryContextId: 'telemetry-2',
+ configContextId: 'config-1',
+ }), false);
+ assert.equal(association.isTelemetryAssociationUsable(confirmed, {
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-2',
+ }), false);
+ assert.equal(association.isTelemetryAssociationUsable(confirmed, {
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ telemetryDeviceName: 'FW-COM-OTHER',
+ configHostname: 'FW-AVR-01',
+ }), false);
+ const selected = association.createSelectedTelemetryAssociation({
+ telemetryDeviceName: 'FW-AVR-01',
+ configHostname: 'FW-AVR-01',
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ });
+ assert.equal(association.isTelemetryAssociationUsable(selected, {
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ telemetryDeviceName: 'FW-AVR-01',
+ configHostname: 'FW-OTHER',
+ }), false);
+ assert.deepEqual(
+ association.refuseTelemetryConfigAssociation(confirmed),
+ { status: 'unassociated', code: 'CONFIG_TELEMETRY_ASSOCIATION_REFUSED', association: null },
+ );
+});
+
+test('plusieurs équipements télémétrie imposent une sélection exacte avant toute association', () => {
+ const flows = [
+ flow({ devname: 'FW-A', devid: 'FGT-A', srcip: '10.250.16.10' }),
+ flow({ devname: 'FW-B', devid: 'FGT-B', srcip: '10.250.16.11' }),
+ ];
+ const multi = association.evaluateTelemetryConfigAssociation(
+ flows,
+ config({ identity: { hostname: 'FW-B', devid: null } }),
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-1' },
+ );
+ assert.equal(multi.status, 'selection_required');
+ assert.equal(multi.code, 'CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED');
+ assert.deepEqual(multi.telemetryDeviceNames, ['FW-A', 'FW-B']);
+ const selected = association.evaluateTelemetryConfigAssociation(
+ flows,
+ config({ identity: { hostname: 'FW-B', devid: 'FGT-B' } }),
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-1', telemetryDeviceName: 'FW-B' },
+ );
+ assert.equal(selected.status, 'matched');
+ assert.equal(selected.telemetryDeviceName, 'FW-B');
+});
+
+test('une sélection utilisateur exacte peut être persistée sans confirmation de nom', () => {
+ assert.equal(typeof association.createSelectedTelemetryAssociation, 'function');
+ const selected = association.createSelectedTelemetryAssociation({
+ telemetryDeviceName: 'FW-B',
+ configHostname: 'FW-B',
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ });
+ assert.equal(selected.selectedByUser, true);
+ assert.equal(selected.confirmedByUser, false);
+ assert.equal(association.isTelemetryAssociationUsable(selected, {
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ telemetryDeviceName: 'FW-B',
+ configHostname: 'FW-B',
+ }), true);
+});
+
+test('l’association ne fait aucun rapprochement flou entre deux noms proches', () => {
+ const result = association.evaluateTelemetryConfigAssociation(
+ [flow({ devname: 'FW-AVR-01-PROD' })],
+ config(),
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-1' },
+ );
+ assert.equal(result.status, 'confirmation_required');
+ assert.equal(result.telemetryDeviceName, 'FW-AVR-01-PROD');
+ assert.equal(result.configHostname, 'FW-AVR-01');
+});
+
+test('une confirmation ne contourne jamais une contradiction serial ou réseau', () => {
+ const confirmed = association.createConfirmedTelemetryAssociation({
+ telemetryDeviceName: 'FW-COM',
+ configHostname: 'FW-AVR-01',
+ telemetryContextId: 'telemetry-1',
+ configContextId: 'config-1',
+ confirmedAt: '2026-08-23T12:00:00.000Z',
+ });
+ const result = association.evaluateTelemetryConfigAssociation(
+ [flow({ devname: 'FW-COM', devid: 'FGT-WRONG' })],
+ config(),
+ { telemetryContextId: 'telemetry-1', configContextId: 'config-1' },
+ confirmed,
+ );
+ assert.equal(result.status, 'contradiction');
+ assert.equal(result.code, 'CONFIG_TELEMETRY_MISMATCH');
+ assert.equal(result.validation.errors.length > 0, true);
+});
diff --git a/docs/ADDRESS_SELECTION.md b/docs/ADDRESS_SELECTION.md
index 6ed0b0b..89c7a40 100644
--- a/docs/ADDRESS_SELECTION.md
+++ b/docs/ADDRESS_SELECTION.md
@@ -12,6 +12,15 @@ La télémétrie et la configuration FortiGate ne correspondent pas.
Une identité absente reste inconnue et n’est jamais inventée. Le mode avertissement n’est conservé que lorsqu’une preuve positive indépendante existe (identité concordante ou IP contenue dans un réseau d’interface connu) ; une capture sans preuve positive est refusée. Les flux dont l’identité est partielle sont comptés et signalés, même si d’autres flux sont complets. Un cluster HA ou plusieurs équipements ne passent que si les membres et la sélection technique sont explicites, et le membre observé doit correspondre exactement au membre sélectionné. Plusieurs VDOM sans sélection sont refusés.
+## Association télémétrie ↔ configuration
+
+- `devname === hostname` est la seule correspondance automatique ; aucun préfixe, suffixe ou rapprochement approchant n’est utilisé.
+- Pour un seul `devname` différent, le serveur conserve la configuration en attente et renvoie `409 CONFIG_TELEMETRY_ASSOCIATION_REQUIRED` avec les deux noms. La configuration ne devient utilisable qu’après l’action explicite `confirm` sur `/api/deploy/config-association`.
+- Lorsque plusieurs `devname` exacts sont présents, le serveur renvoie `409 CONFIG_TELEMETRY_DEVICE_SELECTION_REQUIRED` et n’effectue aucun mélange. La sélection d’un nom exact est obligatoire ; un hostname différent demande ensuite la confirmation utilisateur.
+- Une association confirmée est stockée côté session avec `telemetryDeviceName`, `configHostname`, `confirmedByUser`, `confirmedAt`, `telemetryContextId` et `configContextId`. Ces champs sont inclus dans le cache serveur, l’export/import `.ffws` et l’historique workspace.
+- Une nouvelle télémétrie, un changement de `devname` ou un nouvel upload de configuration invalide l’association précédente. Toute contradiction serial/devid, HA, VDOM ou interface/réseau reste un `422 CONFIG_TELEMETRY_MISMATCH` ; la confirmation ne la contourne jamais.
+- `refuse` efface la configuration en attente et laisse la session non associée afin de revenir au choix/import d’une configuration.
+
## Les trois choix dans le drawer Source/Destination
1. **Objet FortiGate existant** — les objets subnet qui contiennent toutes les IP observées sont affichés directement. Le choix par défaut suit le longest-prefix match : préfixe le plus long, puis nom stable. Le drawer affiche le CIDR, le nombre d’hôtes observés et le nombre d’IP non observées avant `Utiliser cet objet`.