// ============================================================================ // Imara - the traffic map: servers in columns by their tier label, lines for // the TCP connections recorded in the last 24 hours, coloured by what Imara // does with each connection at its destination (an estimate: terms.js // connectionVerdict). Click a server to see its blast radius. // ============================================================================ const { useState: useStateT, useMemo: useMemoT } = React; // Columns follow the tier labels the servers carry: common tiers in their // usual left-to-right order, then any others alphabetically, then OTHER for // servers without a tier label. Set from the servers on each render. const COMMON_TIERS = ["edge", "lb", "adc", "web", "app", "api", "mq", "cache", "infra", "db"]; let TIER_X = { other: 500 }; let TIER_ORDER = ["other"]; function setColumns(SERVERS) { const present = Array.from(new Set((SERVERS || []).map((s) => s.tier).filter(Boolean))); present.sort((a, b) => { const ia = COMMON_TIERS.indexOf(a), ib = COMMON_TIERS.indexOf(b); return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b); }); const cols = present.concat((SERVERS || []).some((s) => !s.tier) ? ["other"] : []); const n = Math.max(1, cols.length); TIER_X = {}; cols.forEach((t, i) => { TIER_X[t] = Math.round(75 + (n === 1 ? 425 : (i * 850) / (n - 1))); }); if (TIER_X.other == null) TIER_X.other = 925; TIER_ORDER = cols.length ? cols : ["other"]; } // One colour per application label, chosen from the name (no fixed list). const APP_PALETTE = ["#38bdf8", "#a78bfa", "#34d399", "#f59e0b", "#f472b6", "#22d3ee"]; const appColor = (app) => { if (!app) return "#94a3b8"; let h = 0; for (const ch of String(app)) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return APP_PALETTE[h % APP_PALETTE.length]; }; const VERDICT = { allowed: { c: "#34d399", label: "allowed" }, open: { c: "#fbbf24", label: "open: default deny off" }, blocked: { c: "#f43f5e", label: "blocked" }, unknown: { c: "#94a3b8", label: "unknown" }, }; const verdictOf = (e) => VERDICT[e.verdict] || VERDICT.unknown; const FRESH_C = { live: "#22c55e", stale: "#f59e0b", silent: "#ef4444" }; const tierCol = (s) => (s.tier && TIER_X[s.tier] != null ? s.tier : "other"); function layout(SERVERS) { const cols = {}; (SERVERS || []).forEach((s) => { (cols[tierCol(s)] ||= []).push(s); }); const pos = {}; const top = 60, bot = 540; // A column with one server sits a little above or below the middle, in // turn, so a line between two columns does not run through the server // of the column between them. TIER_ORDER.forEach((tier, ci) => { const list = (cols[tier] || []).slice().sort((a, b) => (a.app || "") === (b.app || "") ? (a.hostname || "").localeCompare(b.hostname || "") : (a.app || "").localeCompare(b.app || "")); const n = list.length; list.forEach((s, i) => { const y = n === 1 ? (top + bot) / 2 + (ci % 2 ? 45 : -45) : top + (i / (n - 1)) * (bot - top); pos[s.agent_id] = { x: TIER_X[tier], y, s }; }); }); return pos; } // CMP-{SITE}-{ROLE}-V{NNN} hostnames get a compact label; anything else is // shown as-is (trimmed), never as "undefined". function shortName(hostname) { const h = hostname || ""; const p = h.split(".")[0].split(/[-_]/); if (p.length >= 4 && /^V\d+$/i.test(p[3])) return `${p[2]} · ${p[1]} · ${p[3]}`; return h.length > 16 ? h.slice(0, 15) + "…" : h; } function TopologyMap({ height = 560, onOpenHost, onCreateRule }) { const T = window.ImaraTerms; const SERVERS = window.MS_DATA.SERVERS || []; // Estimates follow the latest evidence and its age: worked out again when // the loaded data changes (each list is replaced, never edited in place) // and every minute (report ages), not on every hover. const MD = window.MS_DATA; const minute = Math.floor(T.serverNow() / 60000); const TOPO_EDGES = useMemoT(() => T.edgesWithVerdicts(MD), [MD.SERVERS, MD.byId, MD.INTENTS, MD.GROUPS, MD.TOPO_EDGES, MD.POLICY_INCOMPLETE, MD.FW_VERSION, minute]); const [appFilter, setAppFilter] = useStateT("all"); const [showDenied, setShowDenied] = useStateT(true); const [sel, setSel] = useStateT(null); const [hover, setHover] = useStateT(null); const [find, setFind] = useStateT(""); const [allEdges, setAllEdges] = useStateT(false); // A large fleet cannot be drawn readably: above MAP_LIMIT servers, the // PER_COLUMN busiest servers of each column in the chosen application, // plus the selected server and the servers it talks to. Exposure still // counts every connection. const MAP_LIMIT = 80, PER_COLUMN = 14; const pool = appFilter === "all" ? SERVERS : SERVERS.filter((s) => s.app === appFilter); const drawnIds = useMemoT(() => { if (SERVERS.length <= MAP_LIMIT) return null; const flowsBy = {}; TOPO_EDGES.forEach((e) => { flowsBy[e.from] = (flowsBy[e.from] || 0) + (e.flows || 0); flowsBy[e.to] = (flowsBy[e.to] || 0) + (e.flows || 0); }); const byColumn = {}; pool.forEach((s) => { (byColumn[s.tier || "other"] ||= []).push(s); }); const keep = new Set(); Object.values(byColumn).forEach((list) => list.slice().sort((a, b) => (flowsBy[b.agent_id] || 0) - (flowsBy[a.agent_id] || 0)).slice(0, PER_COLUMN).forEach((s) => keep.add(s.agent_id))); // The selected server and the 40 servers it talks to most. if (sel) { keep.add(sel); const nb = {}; TOPO_EDGES.forEach((e) => { if (e.from === sel) nb[e.to] = (nb[e.to] || 0) + (e.flows || 0); if (e.to === sel) nb[e.from] = (nb[e.from] || 0) + (e.flows || 0); }); Object.keys(nb).sort((a, b) => nb[b] - nb[a]).slice(0, 40).forEach((id) => keep.add(id)); } return keep; }, [SERVERS, TOPO_EDGES, appFilter, sel]); const shownServers = drawnIds ? SERVERS.filter((s) => drawnIds.has(s.agent_id)) : SERVERS; setColumns(shownServers); // Re-laid out whenever the fleet, the application or the drawn servers change. const pos = useMemoT(() => layout(shownServers), [SERVERS, drawnIds]); const showOther = TIER_ORDER.includes("other"); // Which servers a large fleet's map shows: said in the tooltip of the // header count and in the map's help, not in a line of its own. const drawnNote = !drawnIds ? "" : (shownServers.length < pool.length ? `Busiest ${PER_COLUMN} servers per column: ${shownServers.length} of ${pool.length.toLocaleString("en-US")}${appFilter !== "all" ? " in this application" : ""}` : `All ${pool.length} servers of this application`) + (sel ? ", plus up to 40 top peers of the selected server" : "") + ". " + (shownServers.length < pool.length ? "Pick an application, or click a server to add its peers." : "Click a server to add its peers."); // Exposure: every connection that is NOT blocked counts as a path. Open and // "cannot tell" connections count too, so exposure is never understated. const reachable = (e) => e.verdict !== "blocked"; const reach = useMemoT(() => { if (!sel) return null; const out = {}, inb = {}; TOPO_EDGES.forEach((e) => { if (!reachable(e)) return; (out[e.from] ||= []).push(e.to); (inb[e.to] ||= []).push(e.from); }); const bfs = (adj) => { const seen = new Set([sel]); const q = [sel]; while (q.length) { const cur = q.shift(); (adj[cur] || []).forEach((n) => { if (!seen.has(n)) { seen.add(n); q.push(n); } }); } seen.delete(sel); return seen; }; const seen = bfs(out); // transitive egress — what this host could reach if compromised const reachedBy = bfs(inb); // transitive ingress — who could pivot into this host const directIn = new Set(inb[sel] || []); const egressEdges = TOPO_EDGES.filter((e) => e.from === sel); // direct outbound, with port const ingressEdges = TOPO_EDGES.filter((e) => e.to === sel); // direct inbound, with port const openPaths = TOPO_EDGES.filter((e) => (e.from === sel || e.to === sel) && e.verdict === "open").length; return { reach: seen, reachedBy, directIn, egressEdges, ingressEdges, openPaths }; }, [sel, TOPO_EDGES]); const focus = hover || sel; const focusEdges = useMemoT(() => { if (!focus) return null; const set = new Set(); TOPO_EDGES.forEach((e, i) => { if (e.from === focus || e.to === focus) set.add(i); }); return set; }, [focus, TOPO_EDGES]); const visible = (s) => appFilter === "all" || s.app === appFilter; const selServer = sel ? window.MS_DATA.byId[sel] : null; // Several services between the same two servers get separate curves, so // one verdict never hides another. // Keyed by the pair of servers in either direction, so A to B and B to A // are drawn apart too; arrowheads show the direction. const pairSlot = {}; const pairCount = {}; const pairKey = (e) => [e.from, e.to].sort().join("|"); TOPO_EDGES.forEach((e) => { const k = pairKey(e); pairCount[k] = (pairCount[k] || 0) + 1; }); // Curves bow out sideways from the straight line between the two // servers, each service of a pair a little further, measured the same way // for both directions; servers in one column bow out more, so their lines // never lie on top of each other. function edgePath(a, b, e) { const k = pairKey(e); const slot = (pairSlot[k] = (pairSlot[k] == null ? 0 : pairSlot[k] + 1)); const [p, q] = e.from < e.to ? [a, b] : [b, a]; const dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1; const nx = dy / len, ny = -dx / len; const sameColumn = Math.abs(dx) < 1; const off = Math.min(60, len * 0.12) + (sameColumn ? 40 : 0) + (slot - (pairCount[k] - 1) / 2) * 22; const cx = (a.x + b.x) / 2 + nx * off, cy = (a.y + b.y) / 2 + ny * off; return `M${a.x},${a.y} Q${cx},${cy} ${b.x},${b.y}`; } // Blocked connections are drawn last, so other lines never hide them. const DRAW_ORDER = { allowed: 0, open: 1, unknown: 2, blocked: 3 }; const drawn = TOPO_EDGES.map((e, i) => ({ e, i })).sort((x, y) => (DRAW_ORDER[x.e.verdict] || 2) - (DRAW_ORDER[y.e.verdict] || 2) || x.i - y.i); const maxFlows = Math.max(1, ...TOPO_EDGES.map((e) => e.flows || 0)); return (
Blast radius. Click a server to see what it could reach if compromised, directly or through other servers. Uses recorded connections not confirmed blocked, unknown ones included.
Colours. Green: allowed by a rule. Amber, dashed: open, default deny is off on the destination. Red: would be blocked. Grey, dashed: cannot confirm.
Estimated from each destination's firewall report and rules. Only TCP is recorded. Group rules use the group's members as of the destination's last firewall update. A server that joined since (new or relabelled) may not be covered until the next update. Any change approved for that destination triggers one.
Columns. Servers are placed by tier label, from edge (left) to databases (right); servers without one are under OTHER. Traffic usually flows left to right, so reverse flows stand out.
{drawnIds &&Servers shown. {drawnNote}
}Counts the connections not confirmed blocked, unknown ones included, among the latest {T.plural(window.MS_DATA.FLOWS_COUNT || 0, "connection")}{window.MS_DATA.FLOWS_SINCE ? ` (since ${new Date(window.MS_DATA.FLOWS_SINCE).toLocaleString()})` : ""}. Both servers record a connection between them. Connections outside that sample are not counted.
Hover over a connection below to see the reason for its colour.