283 lines
13 KiB
HTML
283 lines
13 KiB
HTML
{% extends 'base.html' %}
|
|
{% block title %}Carte flux{% endblock %}
|
|
{% block content %}
|
|
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-xl font-bold text-cyber-accent">Carte des flux réseau</h2>
|
|
<button onclick="resetZoom()" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">Reset vue</button>
|
|
</div>
|
|
|
|
<!-- Filtres -->
|
|
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
|
|
<form method="GET" action="/audit-full/flow-map" class="flex gap-2 items-center flex-1">
|
|
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
|
|
<option value="">Tous</option>
|
|
<optgroup label="Zones">
|
|
{% for z in all_zones %}<option value="{{ z.code }}" {% if domain_filter == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}
|
|
</optgroup>
|
|
<optgroup label="Domaines">
|
|
{% for d in all_domains %}<option value="{{ d.code }}" {% if domain_filter == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
|
|
</optgroup>
|
|
</select>
|
|
<div style="position:relative" class="flex-1">
|
|
<input type="text" name="server" value="{{ server_filter }}" placeholder="Serveur (ex: vptrabkme1)..." class="text-xs py-1 px-2 w-full font-mono" id="server-input" autocomplete="off" list="server-list">
|
|
<datalist id="server-list">
|
|
{% for s in audited_servers %}<option value="{{ s.hostname }}">{% endfor %}
|
|
</datalist>
|
|
</div>
|
|
<button type="submit" class="btn-primary px-3 py-1 text-xs">Generer</button>
|
|
{% if domain_filter or server_filter %}<a href="/audit-full/flow-map" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>{% endif %}
|
|
</form>
|
|
<span class="text-xs text-gray-500">
|
|
{% if server_filter %}Serveur: <span class="text-cyber-accent font-mono">{{ server_filter }}</span>
|
|
{% elif domain_filter %}Domaine/Zone: <span class="text-cyber-accent">{{ domain_filter }}</span>
|
|
{% else %}Vue globale{% endif %}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Map SVG -->
|
|
<div class="card" style="position:relative;overflow:hidden;height:700px;background:#0a0e17;" id="map-container">
|
|
<svg id="flow-svg" width="100%" height="100%" style="cursor:grab;">
|
|
<defs>
|
|
<marker id="arrow" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="8" markerHeight="6" orient="auto">
|
|
<path d="M0,0 L10,3 L0,6 Z" fill="#22c55e" opacity="0.6"/>
|
|
</marker>
|
|
</defs>
|
|
<g id="svg-root">
|
|
<g id="links-layer"></g>
|
|
<g id="nodes-layer"></g>
|
|
</g>
|
|
</svg>
|
|
<div id="tooltip" style="display:none;position:absolute;background:#1a1f2e;border:1px solid #22c55e;padding:6px 10px;border-radius:4px;font-size:11px;color:#e2e8f0;pointer-events:none;z-index:10;max-width:300px;"></div>
|
|
</div>
|
|
|
|
<!-- Legende -->
|
|
<div class="flex gap-4 mt-2 text-xs text-gray-500">
|
|
<span><span style="color:#22c55e;">---></span> Flux réseau</span>
|
|
<span id="stats-nodes">0 serveurs</span>
|
|
<span id="stats-links">0 flux</span>
|
|
</div>
|
|
|
|
<!-- Tableau flux en dessous -->
|
|
{% if flows %}
|
|
<details class="card mt-4">
|
|
<summary class="p-3 cursor-pointer text-sm text-gray-400">Tableau des flux ({{ flows|length }})</summary>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full table-cyber text-xs">
|
|
<thead><tr>
|
|
<th class="p-2">Dir</th><th class="text-left p-2">Source</th><th class="text-left p-2">Destination</th>
|
|
<th class="p-2">Port</th><th class="p-2">Process</th><th class="p-2">State</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for f in flows %}
|
|
<tr class="{% if f.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
|
|
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
|
|
<td class="p-2 font-mono text-cyber-accent">{{ f.source_hostname }}</td>
|
|
<td class="p-2 font-mono {% if f.dest_hostname %}text-cyber-accent{% else %}text-gray-400{% endif %}">{{ f.dest_hostname or f.dest_ip }}</td>
|
|
<td class="p-2 text-center font-bold">{{ f.dest_port }}</td>
|
|
<td class="p-2 text-center">{{ f.process_name }}</td>
|
|
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if app_map %}
|
|
<details class="card mt-4">
|
|
<summary class="p-3 cursor-pointer text-sm text-gray-400">Carte applicative ({{ app_map|length }} applis)</summary>
|
|
<div class="grid grid-cols-2 gap-3 p-3">
|
|
{% for app_name, app in app_map.items() %}
|
|
<div class="card p-3">
|
|
<span class="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
|
|
<span class="badge badge-blue ml-2">{{ app.servers|length }}</span>
|
|
{% if app.ports %}<span class="text-xs text-gray-500 ml-2">ports: {{ app.ports|join(', ') }}</span>{% endif %}
|
|
<div class="mt-1">{% for s in app.servers %}<span class="text-xs font-mono text-cyber-accent">{{ s.hostname }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
<script>
|
|
// Donnees flux depuis le serveur — uniquement inter-serveurs (dest_hostname connu), pas de self-loop, dedupliques
|
|
var rawFlows = [
|
|
{% for f in flows %}{% if f.dest_hostname and f.source_hostname != f.dest_hostname %}
|
|
{src:"{{f.source_hostname}}",dst:"{{f.dest_hostname}}",port:{{f.dest_port}},proc:"{{f.process_name}}",state:"{{f.state}}",cnt:{{f.cnt}}},
|
|
{% endif %}{% endfor %}
|
|
];
|
|
|
|
// Deduplication: une seule fleche par paire src->dst (agglomerer les ports)
|
|
var linkMap = {};
|
|
rawFlows.forEach(function(f) {
|
|
var key = f.src + ">" + f.dst;
|
|
if (!linkMap[key]) {
|
|
linkMap[key] = {src: f.src, dst: f.dst, ports: [], procs: [], cnt: 0};
|
|
}
|
|
if (linkMap[key].ports.indexOf(f.port) === -1) linkMap[key].ports.push(f.port);
|
|
if (f.proc && linkMap[key].procs.indexOf(f.proc) === -1) linkMap[key].procs.push(f.proc);
|
|
linkMap[key].cnt += f.cnt;
|
|
});
|
|
var links = Object.values(linkMap);
|
|
|
|
// Noeuds uniques
|
|
var nodeSet = {};
|
|
links.forEach(function(l) { nodeSet[l.src] = true; nodeSet[l.dst] = true; });
|
|
var nodes = Object.keys(nodeSet).map(function(name, i) {
|
|
return {id: name, x: 400 + Math.random() * 600, y: 200 + Math.random() * 400, vx: 0, vy: 0};
|
|
});
|
|
var nodeIdx = {};
|
|
nodes.forEach(function(n, i) { nodeIdx[n.id] = i; });
|
|
|
|
document.getElementById("stats-nodes").textContent = nodes.length + " serveurs";
|
|
document.getElementById("stats-links").textContent = links.length + " flux";
|
|
|
|
// Force-directed layout
|
|
var W = document.getElementById("map-container").clientWidth;
|
|
var H = 700;
|
|
var repulsion = 800;
|
|
var attraction = 0.005;
|
|
var damping = 0.85;
|
|
var iterations = 300;
|
|
|
|
for (var iter = 0; iter < iterations; iter++) {
|
|
// Repulsion entre noeuds
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
for (var j = i + 1; j < nodes.length; j++) {
|
|
var dx = nodes[i].x - nodes[j].x;
|
|
var dy = nodes[i].y - nodes[j].y;
|
|
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
var force = repulsion / (dist * dist);
|
|
var fx = dx / dist * force;
|
|
var fy = dy / dist * force;
|
|
nodes[i].vx += fx; nodes[i].vy += fy;
|
|
nodes[j].vx -= fx; nodes[j].vy -= fy;
|
|
}
|
|
}
|
|
// Attraction via liens
|
|
links.forEach(function(l) {
|
|
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
|
|
if (!a || !b) return;
|
|
var dx = b.x - a.x, dy = b.y - a.y;
|
|
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
var force = dist * attraction;
|
|
a.vx += dx / dist * force; a.vy += dy / dist * force;
|
|
b.vx -= dx / dist * force; b.vy -= dy / dist * force;
|
|
});
|
|
// Appliquer + damping
|
|
nodes.forEach(function(n) {
|
|
n.x += n.vx; n.y += n.vy;
|
|
n.vx *= damping; n.vy *= damping;
|
|
n.x = Math.max(60, Math.min(W - 60, n.x));
|
|
n.y = Math.max(40, Math.min(H - 40, n.y));
|
|
});
|
|
}
|
|
|
|
// Rendu SVG
|
|
var svgRoot = document.getElementById("svg-root");
|
|
var linksLayer = document.getElementById("links-layer");
|
|
var nodesLayer = document.getElementById("nodes-layer");
|
|
var tooltip = document.getElementById("tooltip");
|
|
|
|
function render() {
|
|
linksLayer.innerHTML = "";
|
|
nodesLayer.innerHTML = "";
|
|
|
|
links.forEach(function(l) {
|
|
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
|
|
if (!a || !b || a.hidden || b.hidden) return;
|
|
var line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
line.setAttribute("x1", a.x); line.setAttribute("y1", a.y);
|
|
line.setAttribute("x2", b.x); line.setAttribute("y2", b.y);
|
|
line.setAttribute("stroke", "#22c55e");
|
|
line.setAttribute("stroke-width", Math.min(3, 0.5 + l.cnt * 0.1));
|
|
line.setAttribute("stroke-opacity", "0.5");
|
|
line.setAttribute("marker-end", "url(#arrow)");
|
|
line.onmouseenter = function(e) {
|
|
tooltip.style.display = "block";
|
|
tooltip.style.left = e.offsetX + 10 + "px";
|
|
tooltip.style.top = e.offsetY - 10 + "px";
|
|
tooltip.innerHTML = "<b>" + l.src + "</b> → <b>" + l.dst + "</b><br>Ports: " + l.ports.join(", ") + "<br>Process: " + l.procs.join(", ") + "<br>Connexions: " + l.cnt;
|
|
};
|
|
line.onmouseleave = function() { tooltip.style.display = "none"; };
|
|
linksLayer.appendChild(line);
|
|
});
|
|
|
|
nodes.forEach(function(n) {
|
|
if (n.hidden) return;
|
|
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
g.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
|
|
g.style.cursor = "pointer";
|
|
|
|
var isTarget = (serverFilter && n.id === serverFilter);
|
|
var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
circle.setAttribute("r", isTarget ? "10" : "6");
|
|
circle.setAttribute("fill", isTarget ? "#facc15" : "#22c55e");
|
|
circle.setAttribute("stroke", isTarget ? "#facc15" : "#0a0e17");
|
|
circle.setAttribute("stroke-width", "2");
|
|
g.appendChild(circle);
|
|
|
|
var text = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
text.setAttribute("x", "9"); text.setAttribute("y", "4");
|
|
text.setAttribute("fill", "#94a3b8");
|
|
text.setAttribute("font-size", "9");
|
|
text.setAttribute("font-family", "monospace");
|
|
text.textContent = n.id;
|
|
g.appendChild(text);
|
|
|
|
g.onclick = function() { location.href = "/audit-full?q=" + n.id; };
|
|
g.onmouseenter = function() { circle.setAttribute("r", "8"); circle.setAttribute("fill", "#facc15"); };
|
|
g.onmouseleave = function() { circle.setAttribute("r", "6"); circle.setAttribute("fill", "#22c55e"); };
|
|
|
|
nodesLayer.appendChild(g);
|
|
});
|
|
}
|
|
render();
|
|
|
|
// Pan + Zoom
|
|
var svg = document.getElementById("flow-svg");
|
|
var viewBox = {x: 0, y: 0, w: W, h: H};
|
|
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
|
|
var isPanning = false, startX, startY;
|
|
svg.onmousedown = function(e) { isPanning = true; startX = e.clientX; startY = e.clientY; svg.style.cursor = "grabbing"; };
|
|
svg.onmousemove = function(e) {
|
|
if (!isPanning) return;
|
|
var dx = (e.clientX - startX) * viewBox.w / svg.clientWidth;
|
|
var dy = (e.clientY - startY) * viewBox.h / svg.clientHeight;
|
|
viewBox.x -= dx; viewBox.y -= dy;
|
|
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
startX = e.clientX; startY = e.clientY;
|
|
};
|
|
svg.onmouseup = function() { isPanning = false; svg.style.cursor = "grab"; };
|
|
svg.onmouseleave = function() { isPanning = false; svg.style.cursor = "grab"; };
|
|
svg.onwheel = function(e) {
|
|
e.preventDefault();
|
|
var scale = e.deltaY > 0 ? 1.1 : 0.9;
|
|
var mx = e.offsetX / svg.clientWidth * viewBox.w + viewBox.x;
|
|
var my = e.offsetY / svg.clientHeight * viewBox.h + viewBox.y;
|
|
viewBox.w *= scale; viewBox.h *= scale;
|
|
viewBox.x = mx - (mx - viewBox.x) * scale;
|
|
viewBox.y = my - (my - viewBox.y) * scale;
|
|
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
};
|
|
|
|
function resetZoom() {
|
|
viewBox = {x: 0, y: 0, w: W, h: H};
|
|
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
|
}
|
|
|
|
// Highlight du serveur filtre
|
|
var serverFilter = "{{ server_filter }}";
|
|
if (serverFilter && nodeIdx[serverFilter] !== undefined) {
|
|
var targetNode = nodes[nodeIdx[serverFilter]];
|
|
// Centrer la vue sur ce noeud
|
|
viewBox.x = targetNode.x - W / 2;
|
|
viewBox.y = targetNode.y - H / 2;
|
|
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
}
|
|
</script>
|
|
{% endblock %}
|