// ============================================================================ // Imara - the rule panel (an "intent" in the API): what the rule asks for, // what it would do to recent connections, its approvals and rollout, and the // technical details (YAML, nftables, hashes) for experts and auditors. // // Lifecycle (the Imara server is the source of truth; nothing is optimistic): // Edit -> the rule page when it can show the rule without losing anything, // otherwise "Edit as code" under Technical details. Saving makes a // new version as a draft; servers keep the approved version. // draft or changes requested -> Send for approval -> Approve (N of M) // -> sent to the servers: with several servers, tried on one first; the // others follow 30 s after it confirms (or Apply to all now) // Roll back (during the trial) removes the WHOLE rule from its servers // Restore version N -> that text becomes a new version, approved at once // (it was approved before) and sent to every server straight away // Every action goes through onAction (App), which awaits the server and then // re-reads the rule and the activity log. // ============================================================================ // Pure-JS SHA-256 (FIPS 180-4). Hashes a compiled PREVIEW under Technical // details when window.crypto.subtle is unavailable (a plain-HTTP console is // not a secure context). Returns lowercase hex. function sha256Hex(ascii) { function rr(x, n) { return (x >>> n) | (x << (32 - n)); } const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; const bytes = new TextEncoder().encode(ascii); const l = bytes.length; const withOne = l + 1; const k = (Math.ceil((withOne + 8) / 64) * 64); const m = new Uint8Array(k); m.set(bytes); m[l] = 0x80; const bitLen = l * 8; const dv = new DataView(m.buffer); dv.setUint32(k - 4, bitLen >>> 0); dv.setUint32(k - 8, Math.floor(bitLen / 0x100000000)); const w = new Uint32Array(64); for (let off = 0; off < k; off += 64) { for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); for (let i = 16; i < 64; i++) { const s0 = rr(w[i-15],7) ^ rr(w[i-15],18) ^ (w[i-15] >>> 3); const s1 = rr(w[i-2],17) ^ rr(w[i-2],19) ^ (w[i-2] >>> 10); w[i] = (w[i-16] + s0 + w[i-7] + s1) >>> 0; } let [a,b,c,d,e,f,g,hh] = h; for (let i = 0; i < 64; i++) { const S1 = rr(e,6) ^ rr(e,11) ^ rr(e,25); const ch = (e & f) ^ (~e & g); const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; const S0 = rr(a,2) ^ rr(a,13) ^ rr(a,22); const maj = (a & b) ^ (a & c) ^ (b & c); const t2 = (S0 + maj) >>> 0; hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0; } h = [ (h[0]+a)>>>0,(h[1]+b)>>>0,(h[2]+c)>>>0,(h[3]+d)>>>0,(h[4]+e)>>>0,(h[5]+f)>>>0,(h[6]+g)>>>0,(h[7]+hh)>>>0 ]; } return h.map((x) => (x >>> 0).toString(16).padStart(8, "0")).join(""); } async function sha256Of(text) { if (window.crypto?.subtle) { try { const buf = await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join(""); } catch (e) { /* fall back */ } } return sha256Hex(text); } const shortSha = (hex) => (hex ? hex.slice(0, 12) + "…" + hex.slice(-4) : "-"); const APPROVAL_TONE = { approved: "ok", requested: "warn", rejected: "bad", rolled_back: "bad", draft: "neutral" }; // A version can be restored only if its history recorded the rule text // (older entries, and automatic re-sends, have none). const hasBody = (b) => !!b && typeof b.body === "string" && b.body.trim() !== ""; // The version history: one entry per version, newest first, with every // server it was sent to and the first time it was sent. function versionHistory(bundles) { const by = {}; (bundles || []).forEach((b) => { const v = Number(b.version); const e = by[v] || (by[v] = { version: v, hosts: new Set(), first: b.pushed_at, last: b.pushed_at, bundle: null }); (b.target_agents || []).forEach((h) => e.hosts.add(h)); if (new Date(b.pushed_at) < new Date(e.first)) e.first = b.pushed_at; if (new Date(b.pushed_at) > new Date(e.last)) e.last = b.pushed_at; if (hasBody(b) && (!e.bundle || new Date(b.pushed_at) > new Date(e.bundle.pushed_at))) e.bundle = b; }); return Object.values(by).sort((a, b) => b.version - a.version).map((e) => ({ ...e, hosts: Array.from(e.hosts) })); } // The compile check in plain words. A preview from an Imara server without // the isolation check configured is never shown as passed. function GuardPanel({ verdict }) { const box = (tone, icon, text) => (
{text}
); if (verdict == null) return
Checking…
; if (verdict.error) return box("bad", "OctagonAlert", window.ImaraTerms.plainCheckError(verdict.error) || `Invalid rule: ${verdict.error}`); if (verdict.guard_checked === false || (verdict.guard_checked == null && !verdict.guard_passed)) { return box("neutral", "Info", `Valid. This Imara server does not check whether Imara keeps its connection to these servers.${verdict.guard_note ? ` (${verdict.guard_note})` : ""}`); } if (verdict.guard_passed) return box("ok", "ShieldCheck", "Valid. Imara can still manage these servers."); return box("bad", "OctagonAlert", `Blocked for safety: this would cut these servers off from Imara.${verdict.guard_note ? ` ${verdict.guard_note}` : ""}`); } // Results worked out once per rule object and data (the panel draws often, // and on a large fleet consequences take a moment). Keyed on the rule // object, which is replaced whenever the rule is read again. const PANEL_MEMO = new WeakMap(); function panelMemo(obj, kind, deps, fn) { let m = PANEL_MEMO.get(obj); if (!m) { m = {}; PANEL_MEMO.set(obj, m); } const hit = m[kind]; if (hit && hit.deps.length === deps.length && hit.deps.every((d, i) => d === deps[i])) return hit.value; const value = fn(); m[kind] = { deps, value }; return value; } // Connections in a box that scrolls: 200 at a time with "Show more", and // a search by server name or service once there are many, so every one can // be found before approving. function ConnList({ list, hostName }) { const T = window.ImaraTerms; const [q, setQ] = useState(""); const [limit, setLimit] = useState(200); const text = (e) => `${hostName(e.from)} to ${hostName(e.to)} on ${T.serviceName("tcp/" + e.port)}`; const needle = q.trim().toLowerCase(); const matches = needle ? list.filter((e) => text(e).toLowerCase().includes(needle)) : list; return (
{(list.length > 20 || q) && (
{ setQ(ev.target.value); setLimit(200); }} /> {q && }
)}
{matches.slice(0, limit).map((e, k) => {text(e)})} {matches.length === 0 && No match.} {matches.length > limit && }
); } // The servers a rule is sent to, 20 at a time with problems first: a rule // may apply to thousands of servers. A row shows only what is unusual: a // grey state most servers share (not approved yet, ended, waiting for the // trial) is said once above the list, and a server that reports normally // has no line about it. function RuleServerTable({ servers, onServer, toneColor, protectionTone }) { const T = window.ImaraTerms, D = window.MS_DATA; const rank = { bad: 0, warn: 1, accent: 2, muted: 3, ok: 4 }; const rows = servers.map((s) => ({ s, st: onServer(s) })).sort((a, b) => (rank[a.st.tone] ?? 3) - (rank[b.st.tone] ?? 3)); const pg = usePaged(rows, 20); const shared = sharedStatus(rows.map((r) => r.st)); const every = !!shared && shared.count === rows.length; // then the column is left out const inShared = (st) => !!shared && shared.has(st); const freshColor = { stale: "var(--warn)", silent: "var(--bad)" }; return ( {shared &&
{every ? `All ${T.plural(rows.length, "server")}` : `${shared.count.toLocaleString("en-US")} of ${T.plural(rows.length, "server")}`}: {shared.text}.{statusHelp(rows.filter((r) => inShared(r.st)).map((r) => r.st))}
}
{!every && } {pg.slice.map(({ s, st }) => { const p = T.protectionNow(s, D); return ( {!every && } ); })} {servers.length === 0 && }
ServerRule status{statusHelp(rows.filter((r) => !inShared(r.st)).map((r) => r.st))}Default deny
{s.hostname}
{s.fresh !== "live" &&
{T.freshnessText(s.fresh)}
}
{inShared(st) ? null : st.text}{p ? {T.protectionWord(p.state)} : -}
No server matches this rule's destination now.
); } function IntentDrawer({ intent, onClose, onAction, toast, onEdit }) { const API = window.ImaraAPI; const T = window.ImaraTerms; const [tab, setTab] = useState("summary"); const [body, setBody] = useState(""); const [days, setDays] = useState(14); const [sim, setSim] = useState(null); // {days, vkey, data} const [simLoading, setSimLoading] = useState(false); const [simErr, setSimErr] = useState(""); const [bundles, setBundles] = useState(null); // version history rows (newest first) const [bundlesErr, setBundlesErr] = useState(""); const [approvals, setApprovals] = useState(null); // GET /api/intents/{id}/approvals const [verdict, setVerdict] = useState(null); // compile check of what Technical details previews const [draftNft, setDraftNft] = useState(""); // server-compiled firewall rules of the UNSAVED edit const [compiledView, setCompiledView] = useState("auto"); // auto | pushed | preview const [previewSha, setPreviewSha] = useState(""); const [keyId, setKeyId] = useState(""); const [busy, setBusy] = useState(""); const [confirm, setConfirm] = useState(null); // The version the operator has read: a refresh can bring a newer one into // the open panel, and nothing acts on it until the operator says so. const [seenVersion, setSeenVersion] = useState(null); const [savedCheck, setSavedCheck] = useState(null); // the check of the saved text: {text, error} const editBase = useRef(null); // the version a code edit started from const syncedBody = useRef(""); const lastId = useRef(null); const curKey = useRef(""); const daysRef = useRef(days); daysRef.current = days; const bodyRef = useRef(body); bodyRef.current = body; // The app's search shortcut must not drop an unsaved code edit. useEffect(() => { window.IMARA_UNSAVED_EDIT = !!(intent && body !== intent.body); return () => { window.IMARA_UNSAVED_EDIT = false; }; }, [intent && intent.body, body]); const vkey = intent ? intent.id + ":" + intent.version : ""; // State key: changes on approve/reject/push/trial transitions too, which // add approvals and history without bumping the version. const skey = intent ? [vkey, intent.approval_state, intent.canary_state, intent.status, intent.approvers_count].join(":") : ""; curKey.current = vkey; // Version change (or a different rule): reset per-version views and // re-read the compile check. An unsaved code edit survives a background // refresh; it is replaced only when the editor still holds the old text. useEffect(() => { if (!intent) { lastId.current = null; return; } const idChanged = lastId.current !== intent.id; lastId.current = intent.id; if (idChanged) { setTab("summary"); setDays(14); setBody(intent.body); setCompiledView("auto"); setBundles(null); setBundlesErr(""); setApprovals(null); setSeenVersion(intent.version); editBase.current = null; } else { const prevSynced = syncedBody.current; setBody((cur) => (cur === prevSynced ? intent.body : cur)); } syncedBody.current = intent.body; setDraftNft(""); setVerdict(null); setSim(null); setSimErr(""); const key = vkey, text = intent.body; // Bound to the text it checked, like a manual check: an edit makes it // out of date at once. setSavedCheck(null); API.previewVerdict({ name: intent.name, body: text, ...API.targetOf(intent) }) .then((v) => { if (curKey.current === key) { setVerdict({ ...(v || {}), for: "saved", text }); setSavedCheck({ text, error: (v && v.error) || "", unknown: false }); } }) .catch((e) => { if (curKey.current === key) { setVerdict({ error: (e && e.message) || "the check failed", for: "saved", text }); setSavedCheck({ text, error: e && e.status === 422 ? ((e && e.message) || "the check failed") : "", unknown: !(e && e.status === 422) }); } }); if (API.signingKeyId) API.signingKeyId().then((k) => { if (curKey.current === key) setKeyId(k); }).catch(() => {}); }, [vkey]); // Approvals + version history: re-read on every state transition. The // full history (500 records, each with a compiled firewall) only while // the "Approvals and rollout" tab is open. const fullHistory = tab === "approvals"; useEffect(() => { if (!intent || typeof intent.id !== "number") { setBundles(intent ? [] : null); return; } const key = vkey; API.intentBundles(intent.id, fullHistory ? 500 : undefined) .then((b) => { if (curKey.current === key) { setBundles(b || []); setBundlesErr(""); } }) .catch((e) => { if (curKey.current === key) { setBundles([]); setBundlesErr((e && e.message) || "failed"); } }); API.intentApprovals(intent.id) .then((a) => { if (curKey.current === key) setApprovals(a); }) .catch((e) => { if (curKey.current === key) setApprovals({ error: (e && e.message) || "failed", approvers: [] }); }); }, [skey, fullHistory]); // Impact check: run when the Impact tab is open, for the chosen window, // and again after every version change. function runSim() { if (!intent || typeof intent.id !== "number") return; const key = vkey, d = days; setSimLoading(true); setSimErr(""); API.intentSimulate(intent.id, d) .then((r) => { if (curKey.current === key && daysRef.current === d) setSim({ days: d, vkey: key, data: r || {} }); }) .catch((e) => { if (curKey.current === key && daysRef.current === d) { setSim(null); setSimErr((e && e.message) || "the check failed"); } }) .finally(() => { if (curKey.current === key) setSimLoading(false); }); } useEffect(() => { if (tab !== "impact" || !intent) return; if (sim && sim.vkey === vkey && sim.days === days) return; runSim(); }, [tab, days, vkey]); // Which firewall rules Technical details shows: an unsaved-edit preview, // the latest set SENT to a server, or a preview of the saved text. The hash // shown always belongs to the text shown. const latest = Array.isArray(bundles) && bundles.length ? bundles[0] : null; const draftCurrent = !!draftNft && !!verdict && verdict.text === body; const view = draftCurrent ? "draft" : (compiledView === "auto" ? (latest ? "pushed" : "preview") : (compiledView === "pushed" && !latest ? "preview" : compiledView)); const previewText = view === "draft" ? draftNft : (intent ? (intent.nft || (verdict && verdict.for === "saved" && verdict.artifact) || "") : ""); const shownText = view === "pushed" ? (latest.artifact || "") : previewText; useEffect(() => { let alive = true; if (view !== "pushed" && previewText) sha256Of(previewText).then((h) => alive && setPreviewSha(h)); else setPreviewSha(""); return () => { alive = false; }; }, [view, previewText]); if (!intent) return null; const D = window.MS_DATA; const byId = D.byId || {}; const hostName = (id) => (byId[id] && byId[id].hostname) || id; const ctx = { groups: D.GROUPS || [], servers: D.SERVERS, intents: D.INTENTS }; const dirty = body !== intent.body; const v = Number(intent.version || 0), av = Number(intent.approved_version || 0), sv = Number(intent.stable_version || 0); const rule = T.parseRule(intent.body); const desc = T.describeRule(rule, ctx); const approvedText = av > 0 ? T.bodyOfVersion(intent, av) : null; const approvedRule = approvedText ? T.parseRule(approvedText) : null; const newerThanApproved = av > 0 && v > av; const servers = intent.target_agent_id ? [byId[intent.target_agent_id]].filter(Boolean) : T.groupMembers(intent.target_selector || {}, D.SERVERS); const inTrial = intent.canary_state === "canarying" || intent.canary_state === "baking"; // As in terms.js: an edit after a withdrawal makes a new draft. const withdrawn = intent.approval_state === "rolled_back" || (intent.canary_state === "rolled_back" && !["draft", "requested", "rejected"].includes(intent.approval_state)); const history = versionHistory(bundles); const editable = T.ruleEditableInForm(rule, ctx, intent); const simData = sim && sim.vkey === vkey && sim.days === days ? sim.data : null; // "Would block" examples from a server that another rule allows there, as // if default deny were on: that rule's sentence (the check replays this // rule on its own). const otherRules = (() => { const top = (simData && simData.top_denied) || []; if (tab !== "impact" || !top.length || !rule.ok || !rule.denyAll || rule.direction === "egress") return []; const byIp = new Map(); (D.SERVERS || []).forEach((x) => (x.approved_ips || []).forEach((ip) => byIp.set(ip, x))); const edges = []; top.forEach((smp, i) => { const src = byIp.get(smp.peer); if (src && (smp.proto || "tcp") === "tcp") servers.forEach((dst) => { if (dst.agent_id !== src.agent_id) edges.push({ from: src.agent_id, to: dst.agent_id, port: smp.port, i }); }); }); const notes = []; if (edges.length) T.edgesWithVerdicts({ ...D, TOPO_EDGES: edges }, { assumeProtection: "on" }).forEach((e) => { if (e.verdict === "allowed" && !notes[e.i]) notes[e.i] = e.why; }); return notes; })(); const approverRows = (approvals && approvals.approvers) || []; // The approvals of the current version as read for this panel (the rule // list does not carry them); the rule row's own count until they load. const need = Math.max(1, intent.required_approvers || 1); const approvalsKnown = !!(approvals && !approvals.error && typeof approvals.count === "number"); const have = approvalsKnown ? approvals.count : (intent.approvers_count || 0); // An approved version with no approvals of its own restores an earlier // approved version (Restore approves at once). const restoredVersion = approvalsKnown && have === 0 && intent.approval_state === "approved" && Number(intent.approved_version) === Number(intent.version); const meUser = (window.IMARA_ME && window.IMARA_ME.user) || ""; const isAuthor = !!meUser && [intent.created_by, intent.last_edited_by, intent.requested_by].includes(meUser); const authorBlocked = need >= 2 && isAuthor; // A newer version arrived while the panel was open and has not been read. const own = !!(window.IMARA_OWN_VERSIONS && window.IMARA_OWN_VERSIONS.has(intent.id + ":" + intent.version)); const unread = seenVersion != null && Number(intent.version) !== Number(seenVersion) && !own; const stale = () => { if (!unread) return false; toast(`This rule changed to version ${intent.version}. Read it, then press "I have read version ${intent.version}".`, "warn"); return true; }; // Buttons: App toasts the server error; the panel only tracks busy state. const act = async (action, opts) => { if (stale()) throw new Error("unread version"); setBusy(action); try { return await onAction(intent, action, opts); } finally { setBusy(""); } }; const quick = (action, opts) => act(action, opts).catch(() => {}); // What a confirmation dialog described; the action is refused if the rule // no longer matches it when the operator confirms. const expect = { version: intent.version, approval_state: intent.approval_state, approved_version: intent.approved_version || 0, canary_state: intent.canary_state || "none" }; // What a change does on the servers (terms.js changeConsequences): per // server, incoming protection turning off or on, services no longer // allowed, port blocks added, widened or removed, outgoing protection. // The data is read at call time (a reload replaces it); approved and // stable texts missing from the cache come from the version history this // panel loaded, when it has them. const withHistory = (it) => { const find = (n) => { const b = (bundles || []).find((x) => Number(x.version) === n && x.body); return b ? b.body : null; }; const avv = Number(it.approved_version || 0), svv = Number(it.stable_version || 0); const out = { ...it }; if (avv > 0 && Number(it.version) !== avv && !it.approved_body) out.approved_body = find(avv); if (svv > 0 && svv !== avv && Number(it.version) !== svv && !it.stable_body) out.stable_body = find(svv); return out; }; const changeCtx = (DD) => ({ groups: DD.GROUPS || [], servers: DD.SERVERS, byId: DD.byId || {}, intents: DD.INTENTS, incomplete: DD.POLICY_INCOMPLETE }); const consequences = (afterBody, it = intent, DD = window.MS_DATA) => T.changeConsequences(withHistory(it), afterBody, changeCtx(DD)); const effectText = () => (approvedRule && approvedRule.ok ? T.describeRule(approvedRule, ctx).text.replace(/^Allows /, "it no longer allows ").replace(/\.$/, "") : ""); const lineColor = { bad: "var(--bad)", warn: "var(--warn)", muted: "var(--text-muted)" }; // A merged line names a few servers; hovering it shows them all. const showLines = (list) => list.map((l, i) =>
{l.text}
); const pendingVersion = newerThanApproved ? `Version ${v} (${intent.approval_state === "requested" ? "waiting for approval" : intent.approval_state === "rejected" ? "changes requested" : "draft"})` : ""; // Does this version switch incoming protection on or off compared with the // approved one? Such a change needs two approvals (plan D3). When the // approved text could not be loaded the console cannot tell, and treats // it as a switch. // The text waiting for approval must pass the check and name only groups // the Imara server can use; otherwise approving cannot work. const pendingBad = T.unusableGroupRefs(withHistory(intent), { groups: D.GROUPS || [], servers: D.SERVERS }, intent.body).filter((b) => b.stuck); const checkFailed = !!(savedCheck && savedCheck.error && savedCheck.text === intent.body); const nameClash = panelMemo(intent, "clash", [D.INTENTS, D.GROUPS, D.SERVERS], () => T.policyNameClash(withHistory(intent), { intents: D.INTENTS, groups: D.GROUPS || [], servers: D.SERVERS, byId: D.byId || {} }, intent.body)); // Worked out once per rule and data, not on every draw of the panel. const memoDeps = [D.INTENTS, D.GROUPS, D.SERVERS, D.POLICY_INCOMPLETE, D.FW_VERSION, Math.floor(T.serverNow() / 60000)]; const cannotWork = (intent.approval_state === "requested" || intent.approval_state === "draft" || intent.approval_state === "rejected") ? panelMemo(intent, "cannotWork", memoDeps, () => T.approvalLines(withHistory(intent), { groups: D.GROUPS || [], servers: D.SERVERS, byId: D.byId || {}, intents: D.INTENTS, incomplete: D.POLICY_INCOMPLETE }).find((l) => l.cannotWork) || null) : null; const approveBlocked = checkFailed ? `Version ${v} fails the check: ${T.plainCheckError(savedCheck.error) || savedCheck.error}` : cannotWork ? cannotWork.text.replace(/^This version cannot work now: /, `Version ${v} cannot work now: `) : pendingBad.length ? `Version ${v} names ${T.groupName(pendingBad[0].group)}, and ${pendingBad[0].why}: the firewall for its servers cannot be built.` : nameClash.length ? `Version ${v} uses the name ${rule.ok ? rule.name : ""}, already used by ${T.joinList(nameClash.map((c) => T.ruleLabel(c.name)))} on the same servers. Imara would reject every rule on those servers.` : ""; // The person looking has approved this version already. const alreadyApproved = !!meUser && approverRows.some((a) => a.approver === meUser); const sw = T.protectionSwitch(withHistory(intent), intent.body); const switchesProtection = sw.sw !== false; const protectionNeedsTwo = switchesProtection && need < 2 && (newerThanApproved || !av); const switchText = sw.kind === "on" ? "This version turns default deny on." : sw.kind === "off" ? "This version turns default deny off." : sw.why; // Groups this rule names that the Imara server cannot use now: its // servers then get no firewall changes at all (terms.js unusableGroupRefs). const badGroups = T.unusableGroupRefs(withHistory(intent), { groups: D.GROUPS || [], servers: D.SERVERS }); // At confirmation: reload everything and recompute; if what the change // does is no longer what the dialog showed, refuse so it can be reviewed. // compute(fresh rule, fresh data) -> lines: the same function the dialog used. const recheck = async (shown, compute) => { if (!window.IMARA_LIVE || !window.ImaraLive) return; await window.ImaraLive.loadAll({ initial: false }); const DD = window.MS_DATA; const fresh = (DD.INTENTS || []).find((x) => x.id === intent.id); if (!fresh || Number(fresh.version) !== Number(intent.version)) throw new Error("the rule changed while this dialog was open. Close it and check the rule again"); const now = T.lineKeys(compute(fresh, DD)); if (JSON.stringify(now) !== JSON.stringify(T.lineKeys(shown))) throw new Error("the outcome of this change is different now (another rule or a server changed). Close the dialog and reopen it to review"); }; // Approving releases the rule once the last approval is given. With // consequences it asks first; without, it still re-checks them with fresh // data before approving. const approveLines = (it = intent, DD = window.MS_DATA) => T.approvalLines(withHistory(it), changeCtx(DD)); const removeLines = (it = intent, DD = window.MS_DATA) => consequences(null, it, DD); // A version that turns incoming protection on: the recorded connections // into its servers that would then be blocked, or may be (the console // cannot tell), leaving out those blocked already (as on the request // page). The version is sent when approved (fresh). // only: the connections to judge (the ones a dialog showed), instead of // the latest recorded ones. const wouldBlock = (only) => { if (sw.kind !== "on") return { cut: [], maybe: [] }; // The rule's servers as loaded now (also when checked again on confirm). const DD = window.MS_DATA; const now = T.ruleServers(withHistory(intent), { servers: DD.SERVERS, byId: DD.byId || {} }); const ids = new Set(now.map((x) => x.agent_id)); const asIf = { ...withHistory(intent), approved_version: intent.version, approved_body: intent.body, canary_state: "none", fresh: true, targets: now.map((x) => ({ agent_id: x.agent_id, status: "applied", version: intent.version })) }; const key = (e) => e.from + ">" + e.to + ":" + e.port; const into = (only || D.TOPO_EDGES || []).filter((e) => ids.has(e.to)); const blockedNow = new Set(T.edgesWithVerdicts({ ...D, TOPO_EDGES: into }).filter((e) => e.verdict === "blocked").map(key)); const after = T.edgesWithVerdicts({ ...D, TOPO_EDGES: into, INTENTS: (D.INTENTS || []).map((i) => (i.id === intent.id ? asIf : i)) }, { assumeProtection: "on" }) .filter((e) => !blockedNow.has(key(e))); return { cut: after.filter((e) => e.verdict === "blocked"), maybe: after.filter((e) => e.verdict === "unknown") }; }; const askApprove = () => { if (stale()) return; const shown = approveLines(); // Opening a sensitive service to any address always asks first. const exposed = T.internetExposure(rule); // The recorded connections, frozen now: confirming judges this same // sample again with fresh rules and servers (on a large fleet the newest // records turn over within seconds), so a connection allowed now but // blocked by then is caught too. const sample = (D.TOPO_EDGES || []).slice(); const { cut, maybe } = wouldBlock(sample); const connKey = (l) => l.map((e) => e.from + ">" + e.to + ":" + e.port).sort().join("|"); const recheckCut = () => { const again = wouldBlock(sample); if (connKey(again.cut) !== connKey(cut) || connKey(again.maybe) !== connKey(maybe)) throw new Error("the outcome for the connections shown is different now (a rule or a server changed). Close the dialog and reopen it to review"); }; if (!shown.length && !exposed.length && !cut.length && !maybe.length) { setBusy("approve"); (async () => { try { await recheck(shown, approveLines); recheckCut(); } catch (e) { toast(`Not approved: ${(e && e.message) || e}`, "bad"); setBusy(""); return; } try { await onAction(intent, "approve", { version: v, expect }); } catch (e) { /* App showed the server error */ } finally { setBusy(""); } })(); return; } setConfirm({ title: `Approve version ${v} of "${T.ruleTitle(intent)}"?`, confirmLabel: "Approve", tone: "danger", body: (
Version {v} goes to its servers once the last approval is given (this one may be it).{shown.length ? " Then:" : ""}
{exposed.length > 0 &&
Opens {T.servicesText(exposed)} to any address, including the internet. Rarely intended.
} {showLines(shown)} {cut.length > 0 &&
Connections that would then be blocked ({cut.length.toLocaleString("en-US")}). Allow what you need with rules before approving.
} {maybe.length > 0 &&
Connections that may then be blocked, cannot confirm ({maybe.length.toLocaleString("en-US")}). The traffic map shows why for each.
} {sw.kind === "on" &&
{cut.length || maybe.length ? "" : "None of the latest connections into these servers would be blocked. "}Based on the latest {T.plural(D.FLOWS_COUNT || 0, "connection")} between your servers{D.FLOWS_SINCE ? `, covering only the last ${T.plural(Math.max(1, Math.round((T.serverNow() - Date.parse(D.FLOWS_SINCE)) / 60000)), "minute")}` : ""}. External connections, UDP and rare jobs (a nightly backup, for example) may be missing.
}
), run: async () => { await recheck(shown, approveLines); recheckCut(); return onAction(intent, "approve", { version: v, expect }); }, }); }; const askReject = () => !stale() && setConfirm({ title: `Request changes to version ${v} of "${T.ruleTitle(intent)}"?`, confirmLabel: "Request changes", tone: "danger", body: "The rule goes back to its author with your reason, which is kept with the rule and in the audit log.", input: { label: "What must change", required: true, placeholder: "What must change before this can be approved" }, run: (reason) => onAction(intent, "reject", { reason, expect }), }); // Apply to all now: the other servers move from the version they keep // to the approved one; possible before the trial server has confirmed it // (for example when it is not reporting), and said so. const promoteLines = (it = intent, DD = window.MS_DATA) => { const h = withHistory(it); const text = T.bodyOfVersion(h, Number(it.approved_version || 0)); // During a trial the other servers run the stable version: comparing it // with the approved one gives what moving them changes. return text == null ? [{ tone: "warn", text: "The approved version's text could not be loaded: cannot confirm what this changes." }] : T.changeConsequences(h, text, changeCtx(DD)); }; const askPromote = () => { if (stale()) return; const shown = promoteLines(); setConfirm({ title: `Apply version ${av} to the other servers now?`, confirmLabel: "Apply to all now", tone: "danger", body: (
Sends version {av} to the other {T.plural(Math.max(0, servers.length - 1), "server")} now, without waiting.{intent.canary_state === "canarying" ? ` The trial server${intent.canary_target ? ` (${hostName(intent.canary_target)})` : ""} has not confirmed it yet.` : ""}
{showLines(shown)}
), run: async () => { await recheck(shown, promoteLines); return onAction(intent, "promote", { expect }); }, }); }; // Delete and withdraw need no approval, although turning protection off // by editing needs two: said when this rule asks for protection somewhere. const asksSomewhere = servers.some((s) => (T.protectionAsked(s, { intents: [withHistory(intent)], groups: D.GROUPS, servers: D.SERVERS })[0] || {}).asks === true); const askWithdraw = () => { if (stale()) return; const shown = removeLines(); setConfirm({ title: `Roll back "${T.ruleTitle(intent)}"?`, confirmLabel: "Roll back", tone: "danger", body: (
Stops the trial and removes the whole rule from every server that has it{sv > 0 && sv !== av ? `, including version ${sv} on the other servers` : ""}: {effectText() || "its rules are removed"}.
{showLines(shown)} {asksSomewhere &&
Takes effect at once, without approval (turning default deny off by editing the rule needs 2 approvals).
}
To return to an earlier version afterwards, restore it from the version history.
), input: { label: "Reason", required: false, placeholder: "For example: the trial server lost traffic on port 8080" }, run: async (reason) => { await recheck(shown, removeLines); return onAction(intent, "rollback", { reason, expect }); }, }); }; const askRestore = (e) => { if (stale()) return; const r = T.parseRule(e.bundle.body); const badRestore = T.unusableGroupRefs(withHistory(intent), { groups: D.GROUPS || [], servers: D.SERVERS }, e.bundle.body).filter((b) => b.stuck); if (badRestore.length) { setConfirm({ title: `Version ${e.version} cannot be restored now`, confirmLabel: "Close", tone: "primary", body: `Version ${e.version} names ${T.joinList(badRestore.map((b) => `${T.groupName(b.group)} (${b.why})`))}, so the firewall for this rule's servers cannot be built. Fix the group first, or edit the rule as code.`, run: async () => {} }); return; } const rs = T.protectionSwitch(withHistory(intent), e.bundle.body); if (rs.sw !== false) { setConfirm({ title: `Version ${e.version} cannot be restored here`, confirmLabel: "Close", tone: "primary", body: `${rs.kind === "on" || rs.kind === "off" ? `Restoring version ${e.version} turns default deny ${rs.kind}.` : rs.why.replace(/^This (text|asks)/, (m, w) => `Version ${e.version} ${w === "asks" ? "asks" : ""}`.replace(/ $/, ""))} A restore is approved at once, but a default deny change needs 2 approvals. Instead, edit the rule as code with the text of version ${e.version} and send it for approval.`, run: async () => {}, }); return; } // A restore also updates servers that left the rule's destination. const restoreLines = (it = intent, DD = window.MS_DATA) => T.changeConsequences(withHistory(it), e.bundle.body, { ...changeCtx(DD), restore: true }); const shown = restoreLines(); setConfirm({ title: `Restore version ${e.version} of "${T.ruleTitle(intent)}"?`, confirmLabel: `Restore version ${e.version}`, tone: "danger", body: (
Version {e.version}: {T.describeRule(r, ctx).text}
{T.describeRule(r, ctx).lines.map((l, i) =>
{l}
)}
It becomes version {v + 1}, approved at once (it was approved before). It goes to all its servers immediately, with no trial.
{newerThanApproved &&
{pendingVersion} is replaced and its text is lost. Copy it from Technical details first if needed.
} {showLines(shown)}
), input: { label: "Reason", required: false, placeholder: `For example: version ${v} broke an application's health checks` }, run: async (reason) => { await recheck(shown, restoreLines); return onAction(intent, "rollback-to", { bundle: e.bundle, reason, expect }); }, }); }; const askDelete = () => { if (stale()) return; const shown = removeLines(); setConfirm({ title: `Delete "${T.ruleTitle(intent)}"?`, confirmLabel: "Delete rule", tone: "danger", body: av > 0 && !withdrawn ? (
Removes this rule from every server that received it: {effectText() || "its rules are removed"}.
{showLines(shown)} {asksSomewhere &&
Takes effect at once, without approval (turning default deny off by editing the rule needs 2 approvals).
}
If removal fails on a server, it keeps the rule until its next update, and a problem is shown. This cannot be undone.
) : withdrawn && intent.status === "error" ? "Removing this rule failed on some servers; deleting it retries the update there. This cannot be undone." : "Not in force on any server: deleting changes nothing on servers. This cannot be undone.", run: async () => { await recheck(shown, removeLines); return onAction(intent, "delete", { expect }); }, }); }; // Closing with an unsaved code edit asks first. const closePanel = () => { if (!dirty) { onClose(); return; } setConfirm({ title: "Discard your unsaved edit?", confirmLabel: "Discard and close", tone: "danger", body: "Your edit under Technical details is not saved. Closing discards it.", run: async () => { setBody(intent.body); onClose(); }, }); }; const edit = () => { if (stale()) return; if (editable.ok && onEdit) { onEdit(intent); return; } setTab("tech"); toast(`This rule cannot be edited in the form (${T.joinList(editable.reasons)}). Edit it as code here.`, "warn"); }; // A code edit saves only onto the version it started from: if someone // saved a newer one meanwhile, nothing is overwritten. const saveDraft = async (confirmed) => { const id = intent.id; const base = editBase.current != null ? editBase.current : intent.version; if (!confirmed && intent.approval_state === "requested") { setConfirm({ title: `Replace version ${v}, which is waiting for approval?`, confirmLabel: `Save as version ${v + 1}`, tone: "danger", body: `Version ${v} is waiting for approval${have ? ` (${have} of ${need} approvals)` : ""}. Saving creates draft version ${v + 1} and cancels the request${have ? " and its approvals" : ""}; send the new version again.`, run: async () => { await saveDraft(true); } }); return; } if (Number(base) !== Number(intent.version)) { toast(`Your edit started from version ${base}, but someone saved version ${intent.version} meanwhile. Copy your text, discard the edit, and redo it on version ${intent.version}.`, "bad"); return; } try { const fresh = await act("save", { body, expect: { version: base, approval_state: intent.approval_state, ...(confirmed && intent.approval_state === "requested" ? { approvers_count: have } : {}) } }); if (fresh && lastId.current === id) { syncedBody.current = fresh.body; setBody(fresh.body); editBase.current = null; } } catch (e) { /* App showed the server error; the edit stays in the editor */ } }; const compilePreview = async () => { const key = vkey, text = body, wasDirty = dirty; const current = () => curKey.current === key && bodyRef.current === text; try { const pv = await API.previewVerdict({ name: intent.name, body: text, ...API.targetOf(intent) }); if (!current()) return; setVerdict({ ...(pv || {}), for: wasDirty ? "draft" : "saved", text }); if (!wasDirty) setSavedCheck({ text, error: (pv && pv.error) || "", unknown: false }); setDraftNft(wasDirty ? (pv && pv.artifact) || "" : ""); toast(wasDirty ? "Checked your edit. Nothing was saved or sent." : "Checked the saved version again. Nothing was sent.", "ok"); } catch (e) { if (!current()) return; setVerdict({ error: (e && e.message) || "the check failed", for: wasDirty ? "draft" : "saved", text }); if (!wasDirty && e && e.status === 422) setSavedCheck({ text, error: (e && e.message) || "the check failed", unknown: false }); setDraftNft(""); toast(`Invalid rule: ${(e && e.message) || "the check failed"}`, "bad"); } }; const copyShown = async () => { if (!shownText) { toast("Nothing to copy", "warn"); return; } const ok = await copyText(shownText); toast(ok ? "Copied the firewall rules" : "Copy failed: the browser blocked clipboard access", ok ? "ok" : "bad"); }; // This rule on one server, in words. const onServer = (s) => { const want = T.expectedVersion(intent, s.agent_id); const row = T.targetRow(intent, s.agent_id); if (withdrawn) return { tone: "warn", text: "rolled back" }; if (approvedRule && approvedRule.ok && approvedRule.expires && approvedRule.expires < new Date(T.serverNow()).toISOString().slice(0, 10)) return { tone: "muted", text: `ended on ${approvedRule.expires}; being removed` }; if (!av) return { tone: "muted", text: "not sent (not approved yet)" }; if (!want) return { tone: "muted", text: "not sent yet (waiting for the trial)" }; if (row) { if (row.status === "error") return { tone: "bad", text: /does not target this host/.test(row.error_text || "") ? "skipped: not in the destination group by its approved addresses" : "failed on this server (see Technical details)" }; if (row.status === "applied" && Number(row.version) === want) return { tone: "ok", text: `version ${want} confirmed` }; if (row.status === "applied") return { tone: "accent", text: `still on version ${row.version}; version ${want} not confirmed yet` }; return { tone: "accent", text: `version ${want} not confirmed yet` }; } if (intent.target_agent_id) return intent.status === "applied" ? { tone: "ok", text: `version ${want} confirmed` } : intent.status === "error" ? { tone: "bad", text: "failed" } : { tone: "accent", text: `version ${want} not confirmed yet` }; if (!Array.isArray(intent.targets)) return { tone: "warn", text: "could not load where this rule was sent" }; return { tone: "muted", text: "no record", help: "No record: a server enrolled after the rule was last sent gets it when it registers, without a record." }; }; const toneColor = { ok: "var(--ok)", accent: "var(--accent)", warn: "var(--warn)", bad: "var(--bad)", muted: "var(--text-faint)" }; const protectionTone = { on: "ok", off: "neutral", waiting: "accent", unknown: "warn" }; // Where the last sent set went: names for a few servers, a count for many. const lastSentTo = latest ? ((ids) => (ids.length > 3 ? T.plural(ids.length, "server") : ids.map(hostName).join(", ")))(latest.target_agents || []) : ""; const tabs = [["summary", "Summary"], ["impact", "Impact"], ["approvals", "Approvals and rollout"], ["tech", "Technical details"]]; const sentence = (d) => (
{d.text}
{d.lines.map((l, k) => (
{l}
))}
); const note = (tone, icon, children) => (
{children}
); return ( { if (!confirm) closePanel(); }} wide> {rule.ok && rule.description && rule.description.trim() !== T.ruleTitle(intent) &&
{rule.description}
}
{tabs.map(([t, l]) =>
setTab(t)}>{l}
)}
{unread && (
This rule changed to version {intent.version} while this panel was open (you were reading version {seenVersion}). Read version {intent.version}, now shown, before you approve, send, edit or delete.
)} {/* ---- SUMMARY ---- */} {tab === "summary" && (
{(intent.approval_state === "requested" || intent.approval_state === "draft" || intent.approval_state === "rejected") && approveBlocked && note("bad", "OctagonAlert", {approveBlocked} Fix the text, or the group (approve an address for one of its servers), before approval.)} {intent.approval_state === "requested" && note("warn", "UserCheck", Version {v} is waiting for approval ({have} of {need}). Review it below and check the Impact tab before approving.{need >= 2 ? " Its author cannot approve it." : ""})} {intent.approval_state === "requested" && protectionNeedsTwo && (sw.kind === "unknown" ? note("warn", "ShieldQuestion", {switchText} It cannot be approved with 1 approval until this is confirmed; the text reloads on the next refresh.) : note("bad", "ShieldAlert", {switchText} It was sent with 1 approval needed, but a default deny change needs 2. Request changes so the author resends it with 2 approvals.))} {T.oddServerGroups(withHistory(intent), { groups: D.GROUPS || [], servers: D.SERVERS }).map((o) => {note("warn", "Copy", {T.plural(o.count, "server")} are now named {o.host}, so this rule applies to all of them. Rename one, or use a group.)})} {badGroups.filter((b) => b.stuck).length > 0 && (() => { const stuck = badGroups.filter((b) => b.stuck); return note("bad", "OctagonAlert", The firewall for this rule's servers cannot be built: {T.joinList(stuck.map((b) => `${T.groupName(b.group)} (${b.side === "from" ? "source" : "destination"}): ${b.why}`))}. Until fixed, no rule change reaches {servers.length ? (servers.length > 5 ? `${T.joinList(servers.slice(0, 5).map((x) => x.hostname))} and ${servers.length - 5} more servers` : T.joinList(servers.map((x) => x.hostname))) : "its servers"}. {stuck.some((b) => b.oneServer) ? "Rename the server back, or change this rule to use its new name or a group." : "Approve an address for a server in the group, or change this rule."}); })()} {badGroups.filter((b) => !b.stuck).length > 0 && note("warn", "ShieldQuestion", This rule applies to no server now: {T.joinList(badGroups.filter((b) => !b.stuck).map((b) => `${T.groupName(b.group)} (${b.why})`))}. If its server was renamed, that server no longer gets this rule, including any default deny it sets. Rename the server back, or rewrite the rule for the new name.)} {intent.approval_state === "rejected" && note("bad", "X", Changes requested{intent.rejection_reason ? `: ${intent.rejection_reason}` : "."} Edit the rule and send it again.)} {intent.error_text && note("bad", "OctagonAlert", /does not target this host/.test(intent.error_text) ? Skipped on a server: it is not in the rule's destination group by its approved addresses. See the servers below; the raw error is under Technical details. : The last change did not reach every server. See the servers below; the raw error is under Technical details.)}
{newerThanApproved ? `Version ${v} (${intent.approval_state === "requested" ? "waiting for approval" : intent.approval_state === "rejected" ? "changes requested" : "draft"})` : "Rule"}
{sentence(desc)} {newerThanApproved && (
Approved: version {av}
{approvedRule ? sentence(T.describeRule(approvedRule, ctx)) :
The text of version {av} could not be loaded.
} {(() => { const lines = panelMemo(intent, "after", memoDeps, () => consequences(intent.body)); return lines.length ? (
If version {v} is approved
{showLines(lines)}
) : null; })()}
)}
Servers ({servers.length})

{intent.target_agent_id ? "This rule is sent to one server." : T.isEmptySelector(intent.target_selector) ? "This rule is sent to every server: its destination has no conditions." : T.isServerGroup({ selector: intent.target_selector }) ? `Sent to the server named ${intent.target_selector._hostname}. If it is renamed, the rule stops applying to it. Any server later given this name would get it.` : `Sent to every server with ${T.selectorText(intent.target_selector || {}).replace(/^servers with /, "")}, including future ones.`}

Default deny status comes from each server's latest firewall report.

)} {/* ---- IMPACT ---- */} {tab === "impact" && (
Impact on recent connectionsChecks version {v} (the current text{newerThanApproved ? `, not approved version ${av}` : ""}) on its own against recent connections on its servers.
{simLoading && checking {days} days…}
{[7, 14, 30].map((d) => )}
Read-only: nothing changes on servers.
{simErr && note("bad", "OctagonAlert", Check failed: {simErr})} {!simData && !simLoading && !simErr &&
Not checked yet.
} {simData && }
)} {/* ---- APPROVALS AND ROLLOUT ---- */} {tab === "approvals" && (
Approvals for version {v}
{restoredVersion ? Approved without new approvals: restores an earlier approved version : approvalsKnown || (approvals && approvals.error) ? {have} of {need} approval{need === 1 ? "" : "s"}{need >= 2 ? " (author excluded)" : ""}{approvals && approvals.error ? " (could not be read)" : ""} : Loading approvals…}
{{ draft: "draft", requested: "waiting for approval", approved: "approved", rejected: "changes requested", rolled_back: "rolled back" }[intent.approval_state] || intent.approval_state}
{!restoredVersion &&
{Array.from({ length: need }).map((_, i) =>
)}
}
{intent.requested_by &&
sent for approval by{intent.requested_by}{intent.requested_at && {agoLong(intent.requested_at)}}
} {approvals && approvals.error &&
The approvals could not be loaded: {approvals.error}
} {approverRows.map((a, i) => (
approved by{a.approver}{agoLong(a.approved_at)}
))} {approvals && !approvals.error && approverRows.length === 0 && !restoredVersion &&
No approvals recorded for version {v}.
}
Rollout{servers.length > 1 && A change reaches all servers only after the trial server confirms it. Imara does not check that your applications still work.}
{servers.length > 1 ? (
{[ ["1", "Trial on one server" + (intent.canary_target ? ` (${hostName(intent.canary_target)})` : ""), intent.canary_state === "canarying"], ["2", "Trial server confirmed; the others follow automatically, 30 seconds after approval at the earliest" + (intent.canary_bake_until ? ` (not before ${new Date(intent.canary_bake_until).toLocaleTimeString()})` : ""), intent.canary_state === "baking"], ["3", "Sent to all its servers", intent.canary_state === "fully_deployed" || (av > 0 && !inTrial && !withdrawn)], ].map(([n, text, on]) => (
{n} {text}
))}
) :
{servers.length === 1 ? "One server: changes are sent to it directly." : "No server matches right now."}
} {withdrawn &&
{note("warn", "Undo2", "Rolled back: the whole rule is removed from its servers. Make it a draft again (or edit it) and send it for approval, or restore an earlier version below.")}
}
Version history
{bundlesErr &&
The version history could not be loaded: {bundlesErr}
}
{history.map((e) => ( ))} {bundles && history.length === 0 && } {bundles === null && }
VersionSent toWhen
{e.version === 0 ? removal (rolled back or ended) : e.version}{e.version === av && !withdrawn ? · approved : ""} {e.hosts.length === 1 ? hostName(e.hosts[0]) : T.plural(e.hosts.length, "server")} {agoLong(e.last)} {e.version !== 0 && (e.version !== av || withdrawn) ? : null}
Never sent to a server yet.
Loading…
{bundles && bundles.length >= 500 &&
Older versions of a rule on many servers may be missing here.One record is kept per server per send, and only the latest 500 are read. Older versions may be missing here and cannot be restored from this list. Server counts cover the records read.
}
)} {/* ---- TECHNICAL DETAILS ---- */} {tab === "tech" && (
Rule text (YAML)Editing here keeps everything the form cannot show. Saving creates draft version {v + 1}; servers keep the approved version until it is approved.