// ============================================================================ // Imara - the "New rule" page (replaces the five-step intent wizard). // // One page: who can connect (From), to which servers (To), on which services, // with a live summary and the checks beside it. In this version every rule is // an incoming rule on the destination group, written as an ordinary intent: // // policy: // description: "" // direction: ingress // from: {group: } // to: {group: } // allow: ["tcp/5432", ...] // // and pushed with target_selector = the destination group's conditions. // Default deny (deny: all) is never written here; it is requested // separately. // // Flow: Save draft (which also runs the impact check, a read-only replay of // recent connections) -> Send for approval, or approve it yourself when one // approval is enough. Every step talks to the server and re-reads the rule; // nothing is optimistic, and a step's result only reaches the page session // that started it. Plain words come from terms.js (window.ImaraTerms). // ============================================================================ const RULE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,47}$/; function rulePart(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); } // The technical name, made from the title: "APP To DB (PostgreSQL)" becomes // "app-to-db-postgresql". Lowercase, at most 48 characters, unique among // existing rules. function nameFromTitle(title, taken) { const base = rulePart(title).slice(0, 44).replace(/-+$/, "") || "rule"; let name = base, n = 2; while (taken.has(name)) name = `${base}-${n++}`; return name; } // The title the form suggests for an inbound rule between two groups. function suggestedTitle(fromName, toName, specs) { if (!fromName || !toName || !specs.length) return ""; return window.ImaraTerms.suggestRuleTitle({ ok: true, direction: "ingress", from: { group: fromName }, to: { group: toName }, allow: specs }); } // A compiler message in plain words, or null (terms.js plainCheckError). function plainCheckError(msg) { return window.ImaraTerms.plainCheckError(msg); } // What a rule says, compared without formatting (so reopening a rule and // saving it unchanged is not "a change"). A title equal to the suggested one // counts as no title: the rule reads the same either way. function ruleMeaning(body) { const T = window.ImaraTerms; const r = T.parseRule(body); if (!r.ok) return "unreadable:" + body; const title = r.description.trim(); return JSON.stringify([r.direction, r.from, r.to, r.allow.slice().sort(), title === T.suggestRuleTitle(r) ? "" : title, r.denyAll, r.denyPorts, r.expires, r.identityUser]); } // "CMP-DC-APP-V001, CMP-DC-DB-V001 and 2 more" function memberLine(members, limit = 3) { const names = members.map((s) => s.hostname || s.agent_id); if (names.length <= limit) return window.ImaraTerms.joinList(names); return names.slice(0, limit).join(", ") + " and " + (names.length - limit) + " more"; } // The toast after "Approve and apply", from the row the server returned. function applyOutcome(row) { if (!row) return "Approved. Open the rule to track the rollout."; if (row.read_back_failed) return "Approved, but the rule could not be read back: server status unknown. Open it from the Rules page."; if (row.approval_state !== "approved") return "Approval recorded, but the rule is not approved yet. Open it to see why."; if (row.canary_state === "canarying" || row.canary_state === "baking") return "Approved. " + window.ImaraTerms.trialFollowText(row, window.MS_DATA.byId || {}); if (row.status === "error") return "Approved, but sending failed on some servers. Open the rule for details."; if (row.status === "applied") return "Approved and confirmed by its servers."; return "Approved. Sending to its servers; the rule shows each confirmation."; } // One choice for a side of the rule page: a button that shows the choice, // and a list to search with one line per option, in sections in the order // the options come (address ranges, groups by kind, single servers); // options that cannot be used say why. Typing highlights the best usable // match (names that start with the text first); arrow keys move the // highlight; Enter takes it, and does nothing before anything is typed or // highlighted. Escape and Tab close the list. let pickerIds = 0; function Picker({ value, onChange, options, placeholder }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [active, setActive] = useState(null); // value of the highlighted option const ref = useRef(null), btnRef = useRef(null); const uid = useRef("picker" + (++pickerIds)).current; useEffect(() => { if (!open) return; const h = (e) => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setQ(""); setActive(null); } }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, [open]); const chosen = options.find((o) => o.value === value); const sections = []; const words = q.trim().toLowerCase().split(/\s+/).filter(Boolean); if (open) { // Every word typed must appear, in any order ("payments app" finds // PAYMENTS · APP TIER). const text = words.join(" "); const rank = (o) => { const t = String(o.title).toLowerCase(); return !text ? 0 : t.startsWith(text) ? 0 : t.includes(text) ? 1 : 2; }; const bySection = new Map(); options.forEach((o) => { if (words.length && !words.every((w) => o.search.includes(w))) return; if (!bySection.has(o.section)) bySection.set(o.section, []); bySection.get(o.section).push(o); }); bySection.forEach((list, sec) => sections.push([sec, text ? list.map((o, i) => [rank(o), i, o]).sort((a, b) => a[0] - b[0] || a[1] - b[1]).map((x) => x[2]) : list])); } // The usable options on screen, in order; the highlighted one. const usable = sections.flatMap(([, l]) => l.slice(0, 60)).filter((o) => !o.reason); const hi = usable.find((o) => o.value === active) || (words.length ? usable[0] : null); const optId = (o) => uid + "-" + String(o.value).replace(/[^A-Za-z0-9_-]/g, "_"); useEffect(() => { if (open && hi) { const el = document.getElementById(optId(hi)); if (el) el.scrollIntoView({ block: "nearest" }); } }, [open, hi && hi.value]); const close = (refocus) => { setOpen(false); setQ(""); setActive(null); if (refocus && btnRef.current) btnRef.current.focus(); }; const pick = (o) => { onChange(o.value); close(true); }; const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); close(true); } else if (e.key === "Tab") close(true); // Tab then moves on from the button else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!usable.length) return; const i = hi ? usable.indexOf(hi) : -1; const next = e.key === "ArrowDown" ? usable[Math.min(usable.length - 1, i + 1)] : usable[Math.max(0, i - 1)]; setActive(next.value); } else if (e.key === "Enter") { e.preventDefault(); if (hi) pick(hi); } }; return (
{open && (
{ setQ(e.target.value); setActive(null); }} />
{sections.map(([sec, list]) => (
{sec}
{list.slice(0, 60).map((o) => ( ))} {list.length > 60 &&
and {(list.length - 60).toLocaleString("en-US")} more: type to filter
}
))} {sections.length === 0 &&
{words.length ? `Nothing matches "${q.trim()}".` : "Nothing to choose from yet."}
}
)}
); } // The services of a rule, as one search: named services, any port typed // as a number ("9000", "udp 514"), and the ports recently seen in traffic // to the destination servers first. A choice adds its ports; the chosen // ones show as chips beside it. function ServicePicker({ specs, onAdd, seen }) { const T = window.ImaraTerms; const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [hi, setHi] = useState(0); const ref = useRef(null), inputRef = useRef(null); useEffect(() => { if (!open) return; const h = (e) => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setQ(""); } }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, [open]); const text = q.trim().toLowerCase(); const pm = /^(?:(tcp|udp)[\s/:]*)?(\d{1,5})$/.exec(text); const portNo = pm && Number(pm[2]) >= 1 && Number(pm[2]) <= 65535 ? Number(pm[2]) : null; const has = (ports) => ports.every((p) => specs.includes(p)); const opts = []; if (portNo) (pm[1] ? [pm[1]] : ["tcp", "udp"]).forEach((proto) => { const spec = `${proto}/${portNo}`; opts.push({ key: spec, section: "Port", ports: [spec], title: T.serviceName(spec) }); }); (seen || []).forEach((x) => { if (text && !portNo && !T.serviceName(x.spec, { hints: true }).toLowerCase().includes(text)) return; if (portNo && !x.spec.endsWith("/" + portNo)) return; opts.push({ key: "seen:" + x.spec, section: "Seen in recent traffic", ports: [x.spec], title: T.serviceName(x.spec), sub: T.plural(x.flows, "connection") }); }); T.SERVICES.forEach((svc) => { const label = svc.label || svc.name; const hay = [label, svc.name, svc.hint].concat(svc.ports).join(" ").toLowerCase(); if (text && !portNo && !hay.includes(text)) return; if (portNo && !svc.ports.some((p) => p.endsWith("/" + portNo))) return; opts.push({ key: svc.id, section: "Services", ports: svc.ports, title: label, sub: svc.ports.map((p) => p.replace("/", " ").toUpperCase()).join(", ") }); }); const usable = opts.filter((o) => !has(o.ports)); const pick = (o) => { onAdd(o.ports); setQ(""); setHi(0); if (inputRef.current) inputRef.current.focus(); }; const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); setOpen(false); setQ(""); } else if (e.key === "ArrowDown") { e.preventDefault(); setHi((i) => Math.min(usable.length - 1, i + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHi((i) => Math.max(0, i - 1)); } else if (e.key === "Enter") { e.preventDefault(); if (text && usable[hi]) pick(usable[hi]); } }; let lastSec = ""; return (
setOpen(true)} onKeyDown={onKey} onChange={(e) => { setQ(e.target.value); setOpen(true); setHi(0); }} style={{ fontSize: 13 }} /> {open && (
{opts.map((o) => { const added = has(o.ports); const head = o.section !== lastSec ?
{o.section}
: null; lastSec = o.section; const isHi = !added && usable.indexOf(o) === hi; return ( {head} ); })} {opts.length === 0 &&
{text ? "No match. Enter a port, for example 9000 or udp 514." : "Type to search."}
}
)}
); } // Incoming protection on the destination servers, from each server's own // firewall report. Never promises that a connection will work. to: the // destination as shown (a group name in capitals, or a server name); // oneServer: the destination is one server. function ProtectionStatement({ to, gp, fromWorld, oneServer }) { if (!to) return null; const T = window.ImaraTerms; const setBy = gp.setBy.length ? ` (set by ${T.joinList(gp.setBy.map(T.ruleLabel))})` : ""; // Why the console cannot tell: one example, not a line per server. const unk = gp.per.filter((p) => p.state === "unknown"); const why = (p) => (p && p.notes && p.notes[0]) || "No recent firewall report."; const lower = (t) => t.charAt(0).toLowerCase() + t.slice(1); const text = { on: `Default deny is on${setBy}. Once approved and applied, this rule allows these connections.`, off: `Default deny is off: no effect until it is turned on (${oneServer ? "server page, Turn on default deny for this server" : "Groups, Turn on default deny"}).${gp.per.some((x) => x.seen && x.seen.blocks) ? ` ${oneServer ? "This server has" : "Some of these servers have"} port blocks, which still apply.` : ""}`, mixed: `Default deny is on for ${gp.counts.on} of ${gp.counts.total} servers${setBy}. There, this rule allows these connections once applied; on the others it has no effect yet.`, waiting: gp.per.some((x) => x.state === "waiting" && x.waitingFor === "off") && !gp.per.some((x) => x.state === "waiting" && x.waitingFor !== "off") ? (oneServer ? "Default deny was just turned off on this server; not confirmed yet." : "Default deny was just turned off on some of these servers; not confirmed yet.") : (oneServer ? "A rule sets default deny; this server has not confirmed it yet." : "A rule sets default deny; not every server has confirmed it yet."), unknown: oneServer ? `Cannot confirm. ${why(unk[0])}` : !unk.length ? "Cannot confirm for every server." : `Cannot confirm for ${unk.length === gp.counts.total ? "these servers" : `${unk.length} of the ${gp.counts.total} servers`}. ${unk.length === 1 ? "On" : "For example, on"} ${unk[0].server.hostname}: ${lower(why(unk[0]))}`, empty: "No servers in this group yet: the rule applies to none until one joins.", }[gp.state]; const tone = gp.state === "on" ? "ok" : gp.state === "off" || gp.state === "empty" ? "neutral" : "warn"; return (
Default deny on {to}{T.protectionWord(gp.state)} From each server's latest firewall report. Imara manages only each server's own firewall. Port blocks in other rules{fromWorld ? " or" : ","} external firewalls{fromWorld ? "" : " or outbound limits on source servers"} can still block these connections.
{text}
); } // The impact check: the rule ON ITS OWN replayed against the connections // recorded on its servers. Never claims that everything is allowed, and // always states what was not assessed. Shared with the rule panel. // otherRules[i]: why the i-th "would block" example stays allowed by another // rule (the rule panel works it out), or empty. function ImpactResult({ impact, rule, upToDate, protectionHint, otherRules }) { const T = window.ImaraTerms; if (!impact) return null; if (impact.error) return
Impact check failed: {impact.error}
; const r = impact.result || {}; const out = !!(rule && rule.ok && rule.direction === "egress"); const dir = (r.by_direction && r.by_direction[out ? "out" : "in"]) || {}; const seen = (dir.allowed || 0) + (dir.denied || 0); const days = r.window_days || 14; const blocks = !!(rule && rule.ok && (rule.denyAll || rule.denyPorts.length)); const hasUdp = !!(rule && rule.ok && rule.allow.concat(rule.denyPorts).some((p) => /^udp\//i.test(p))); const covered = (r.top_allowed || []).filter((s) => s.reason === "allow"); const blocked = r.top_denied || []; const word = out ? "outbound" : "inbound"; const caveats = (

Only TCP connections are recorded: DNS, NTP and other UDP traffic is not assessed.

{!out &&

Connections blocked today never reach these servers, so they are not in this history.

}
); const sample = (s, i, why) => (
{s.peer}{out ? "to" : "on"} {T.serviceName(`${s.proto || "tcp"}/${s.port}`)}· {T.plural(s.flows, "connection")} {why && · {why}}
); const keptByOthers = blocked.map((s, i) => [s, (otherRules || [])[i]]).filter((x) => x[1]); const blockedOnly = blocked.filter((s, i) => !(otherRules || [])[i]); return (
{!upToDate &&
Out of date: the rule changed after this check. Save and check again.
} {r.bundle_notice &&
The check stopped at 50,000 connections, read server by server: some servers or older connections may be missing.
} {!seen ? (
{r.bundle_notice ? `No ${word} connections in the part of the history read: impact cannot be estimated.` : `Not enough history: no ${word} connections ${out ? "from" : "to"} these servers in the last ${days} days. Impact cannot be estimated.`}
) : (
Checked {T.plural(seen, `${word} connection`)} on these servers (last {days} days).{caveats}
{blocks ? (
On its own, this rule would block {Number(dir.denied || 0).toLocaleString("en-US")} of them. {rule.denyPorts.length ? " Its blocked ports stay blocked whatever other rules allow." : ""} {rule.denyAll ? ` ${rule.denyPorts.length ? "The others are" : "They are"} blocked by default deny (no rule allows them); other rules on these servers may still allow some.` : ""}
) : (
On its own, this rule blocks nothing: it only allows traffic.{protectionHint ? " " + protectionHint : ""}
)} {blocks && blockedOnly.length > 0 && (
Would block, for example
{blockedOnly.map((s, i) => sample(s, i))}
)} {blocks && keptByOthers.length > 0 && (
Stay allowed by other rules
{keptByOthers.map((x, i) => sample(x[0], i, x[1]))}
)} {covered.length ? (
Allows, for example
{covered.map((s, i) => sample(s, i))}
) :
None of these connections match what this rule allows.
}
)} {!seen &&
What the check cannot see{caveats}
} {hasUdp &&
Includes a UDP service: this check cannot assess UDP.
}
); } function NewRulePage({ open, onClose, toast, initial, ops }) { const D = window.MS_DATA; const T = window.ImaraTerms; const groups = D.GROUPS || []; const ctx = { groups, servers: D.SERVERS }; // A choice on either side is a built-in address range (world, // private-rfc1918), a group by name, or one server: "server:". const SERVER = "server:"; const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [specs, setSpecs] = useState([]); const [name, setName] = useState(""); const [nameTouched, setNameTouched] = useState(false); const [title, setTitle] = useState(""); // typed title; empty follows the choices const [titleTouched, setTitleTouched] = useState(false); const [approvals, setApprovals] = useState(2); const [minApprovals, setMinApprovals] = useState(1); const [tech, setTech] = useState(false); const [saved, setSaved] = useState(null); // the rule row once saved const [savedBody, setSavedBody] = useState(""); const [createdHere, setCreatedHere] = useState(false); const [attempted, setAttempted] = useState(false); // send or apply was tried const [busy, setBusy] = useState(""); const [err, setErr] = useState(""); const [check, setCheck] = useState({ state: "idle" }); const [impact, setImpact] = useState(null); // {forMeaning, result} | {error} const [leaving, setLeaving] = useState(""); // "" | unsaved | changes | draft const [confirmApply, setConfirmApply] = useState(false); const [checkRetry, setCheckRetry] = useState(0); // bumps re-run the compile check const [confirmReplace, setConfirmReplace] = useState(false); // saving over a version waiting for approval const [more, setMore] = useState(false); // name, approvals and checks const session = useRef(0); const groupMadeAt = useRef(0); // when this page last made a group const madeHere = useRef([]); // groups this page made const summaryRef = useRef(null), apprRef = useRef(null); const editing = !!(initial && initial.edit); const editRow = editing ? initial.edit : null; const editRule = editing ? T.parseRule(editRow.body) : null; const editable = editing ? T.ruleEditableInForm(editRule, ctx, editRow) : { ok: true, reasons: [] }; const app = initial && initial.application ? initial.application : null; // Every open starts a new session; results of an earlier one are dropped. useEffect(() => { session.current++; madeHere.current = []; if (!open) return; const ini = initial || {}; let f = "", t = "", sp = [], ttl = "", nm = "", touched = false; // A one-server group with exactly one server shows as that server. const asPick = (name) => { const g = groups.find((x) => x.name === name); if (g && T.isServerGroup(g)) { const ms = T.groupMembers(g.selector, D.SERVERS); if (ms.length === 1) return SERVER + ms[0].agent_id; } return name || ""; }; if (ini.edit) { const r = T.parseRule(ini.edit.body); // A title equal to the suggested one keeps following the choices. if (r.ok) { f = asPick(r.from && r.from.group); t = asPick(r.to && r.to.group); sp = r.allow.slice(); ttl = r.description.trim() === T.suggestRuleTitle(r) ? "" : r.description; } nm = ini.edit.name; touched = true; } else { if (ini.to) t = String(ini.to).startsWith(SERVER) ? ini.to : asPick(ini.to); else if (ini.selector) { const g = groups.find((x) => T.sameSelector(x.selector, ini.selector)); if (g) t = asPick(g.name); } if (ini.from) f = String(ini.from).startsWith(SERVER) ? ini.from : asPick(ini.from); if (Array.isArray(ini.specs)) sp = ini.specs.filter((p) => T.parsePortSpec(p)); } const appr = ini.edit ? (ini.edit.required_approvers || 1) : ini.application ? (ini.application.required_approvers || 1) : 2; setFrom(f); setTo(t); setSpecs(sp); setTitle(ttl); setTitleTouched(!!ttl.trim()); setName(nm); setNameTouched(touched); setApprovals(appr); setMinApprovals(ini.edit || ini.application ? appr : 1); setTech(false); setSaved(ini.edit || null); setSavedBody(ini.edit ? ini.edit.body : ""); setCreatedHere(false); setAttempted(false); setBusy(""); setErr(""); setCheck({ state: "idle" }); setImpact(null); setLeaving(""); setConfirmApply(false); setConfirmReplace(false); setMore(false); // Closing (or unmounting) ends the session too, so a late answer from // this page never touches the next one. return () => { session.current++; }; }, [open]); // What each group offers on each side. A source is matched by its // servers' approved IP addresses, and the compiler cannot use a group // without any; a destination with no conditions would be every server. const groupInfo = useMemo(() => Object.fromEntries(groups.map((g) => { const sel = g.selector || {}; const members = T.groupMembers(sel, D.SERVERS); const empty = T.isEmptySelector(sel); const addressed = members.some(T.hasApprovedAddress); const noAddr = "None of its servers has an approved IP address yet, so rules cannot use it. Approve addresses on each server's page."; const fromReason = !members.length ? "No servers in this group yet." : !addressed ? noAddr : ""; const toReason = empty ? "It has no conditions, so it means every server. Pick a narrower group." : !members.length ? "No servers in this group yet." : !addressed ? noAddr : ""; return [g.name, { group: g, members, fromReason, toReason }]; })), [groups, D.SERVERS, open]); // Groups Imara offers by itself (each site and subnet the servers report), // made when a rule saved here first uses one. const autos = useMemo(() => T.autoGroups(D.SERVERS, groups), [D.SERVERS, groups, open]); // One server is named in rule text through its one-server group, made // when the rule is saved if it does not exist yet (needsGroup). const serverReason = (s) => T.serverNameIssue(s, D.SERVERS) || (!T.hasApprovedAddress(s) ? "No approved IP address yet, so rules cannot use it. Approve its addresses on its page." : ""); // taken: names proposed for the other side, so two new one-server groups // never get the same name. const resolvePick = (v, taken = []) => { if (!v) return null; if (v.startsWith(SERVER)) { const s = (D.byId || {})[v.slice(SERVER.length)]; if (!s) return { kind: "server", name: "", title: "a server Imara no longer knows", members: [], problem: "Imara no longer knows this server." }; const g = T.serverGroupFor(s, groups); return { kind: "server", server: s, group: g, needsGroup: !g, name: g ? g.name : T.serverGroupName(s, groups.concat(taken.map((n) => ({ name: n })))), title: s.hostname, selector: g ? g.selector : T.serverSelector(s), members: g ? T.groupMembers(g.selector, D.SERVERS) : [s], problem: serverReason(s) }; } if (T.BUILTIN_GROUPS[v]) return { kind: "builtin", name: v, title: T.BUILTIN_GROUPS[v].title, members: [] }; if (v.startsWith("auto:")) { const sel = T.parseAutoId(v); // Stored meanwhile (by this page or someone else): that group. const stored = sel ? groups.find((g) => T.sameSelector(g.selector || {}, sel)) : null; if (stored) return resolvePick(stored.name, taken); // Any automatic selection works, listed or not (a site or subnet // chosen on the Groups page). const a = sel ? (autos.find((x) => x.id === v) || T.autoGroupFor(sel, groups)) : null; if (!sel || !a || !T.groupMembers(sel, D.SERVERS).length) return { kind: "auto", name: "", title: "a group Imara no longer offers", members: [], problem: "No server matches this any more: choose again." }; const name = taken.length ? T.suggestGroupName(sel, groups.concat(taken.map((n) => ({ name: n })))) : a.name; return { kind: "auto", auto: a, needsGroup: true, name, title: T.groupName(name), selector: sel, description: a.description, members: T.groupMembers(sel, D.SERVERS) }; } const gi = groupInfo[v]; return { kind: "group", name: v, title: T.groupName(v), group: gi ? gi.group : null, selector: gi ? gi.group.selector || {} : {}, members: gi ? gi.members : [] }; }; const fromPick = resolvePick(from); const toPick = resolvePick(to, fromPick && fromPick.needsGroup && !(to === from) ? [fromPick.name] : []); const fromName = fromPick ? fromPick.name : "", toName = toPick ? toPick.name : ""; const toLabel = toPick ? toPick.title : ""; const pickKey = (p) => (p.kind === "server" ? "s:" + (p.server && p.server.agent_id) : "a:" + JSON.stringify(p.selector || {})); const needGroups = [fromPick, toPick].filter((p, i, a) => p && p.needsGroup && a.findIndex((q) => q && q.needsGroup && pickKey(q) === pickKey(p)) === i); const pickProblem = [fromPick, toPick].map((p) => (p && p.problem) || "").filter(Boolean)[0] || ""; // The "Single servers" choices, by name, each with the reason it cannot be // used. Worked out once per load (a fleet may hold thousands of servers): // the full check runs only for a server whose name another entry shares, // or that has no name or no approved address. const serverOptions = useMemo(() => { const names = {}; (D.SERVERS || []).forEach((s) => { const n = s.labels && s.labels._hostname; if (n) names[n] = (names[n] || 0) + 1; }); return (D.SERVERS || []).slice().sort((a, b) => String(a.hostname).localeCompare(String(b.hostname))).map((s) => { const n = s.labels && s.labels._hostname; const reason = !n || names[n] > 1 || !T.hasApprovedAddress(s) ? serverReason(s) : ""; return { value: SERVER + s.agent_id, section: "Single servers", title: s.hostname, sub: (s.approved_ips || [])[0] || "", search: [s.hostname || ""].concat(s.approved_ips || []).join(" ").toLowerCase(), reason, reasonShort: !reason ? "" : /same name/.test(reason) ? "same name as another" : /approved IP/.test(reason) ? "needs an approved address" : /name yet|name not recorded/.test(reason) ? "name not recorded yet" : "cannot be used" }; }); }, [D.SERVERS, open]); // Groups in list order (sites, subnets, application tiers, the rest), and // the heading for each kind. const sortedGroups = useMemo(() => T.sortGroups(groups.concat(autos.map((a) => ({ name: a.name, selector: a.selector, auto: a })))), [groups, autos]); const kindTitles = useMemo(() => { const all = groups.concat(autos.map((a) => ({ name: a.name, selector: a.selector }))); return Object.fromEntries(T.GROUP_KINDS.map((k) => [k, T.groupKindTitle(k, all)])); }, [groups, autos]); const taken = useMemo(() => new Set((D.INTENTS || []).filter((i) => !saved || i.id !== saved.id).map((i) => i.name)), [D.INTENTS, saved, open]); // The title is the typed one, or else suggested from the choices; the // technical name is made from the title until saved. const typedTitle = titleTouched ? title.trim() : ""; const autoTitle = suggestedTitle(fromName, toName, specs); const effTitle = typedTitle || autoTitle; const titleShared = !!effTitle && (D.INTENTS || []).some((i) => (!saved || i.id !== saved.id) && T.ruleTitle(i) === effTitle); const autoName = nameFromTitle(effTitle, taken); const effName = editing || saved ? (saved ? saved.name : name) : (nameTouched ? name : autoName); const policyName = editing && editRule && editRule.ok && editRule.name ? editRule.name : effName; const nameOk = RULE_NAME_RE.test(effName); const nameTaken = !saved && taken.has(effName); const picked = !!(fromName && toName && specs.length); const complete = picked && nameOk && !nameTaken && !pickProblem; const hasInput = !!(from || to || specs.length || typedTitle); // Groups made on saving may get other names than proposed: a suggested // title follows the names used. const makeBody = (fName, tName) => [ `policy: ${policyName}`, `description: ${JSON.stringify(typedTitle || suggestedTitle(fName, tName, specs))}`, "direction: ingress", `from: {group: ${JSON.stringify(fName)}}`, `to: {group: ${JSON.stringify(tName)}}`, `allow: [${specs.map((p) => JSON.stringify(p)).join(", ")}]`, ].join("\n") + "\n"; const body = complete ? makeBody(fromName, toName) : ""; const meaning = body ? ruleMeaning(body) : ""; const savedMeaning = savedBody ? ruleMeaning(savedBody) : ""; // An incomplete form never counts as "the saved rule": nothing can be sent // until the form shows exactly what is saved. On a rule that is not a // draft, a new number of approvals is saved with a new version too. const approvalsChanged = !!saved && editing && !["draft", "rejected"].includes(saved.approval_state) && approvals !== (saved.required_approvers || 1); const dirty = !!saved && (!body || meaning !== savedMeaning || approvalsChanged); const targetSelector = toPick && toPick.selector ? toPick.selector : {}; const rule = body ? T.parseRule(body) : { ok: false }; const toMembers = toPick ? toPick.members || [] : []; const gp = T.groupProtection(toMembers, { intents: D.INTENTS, groups: D.GROUPS, servers: D.SERVERS, incomplete: D.POLICY_INCOMPLETE }); // Worked out when the rule, its destination or the data change, not on // every key press (a destination may hold thousands of servers). const coverKey = [body, toPick ? toPick.kind + ":" + toPick.name : "", saved ? saved.id : "", needGroups.map((p) => p.name).join(",")].join("|"); const covering = useMemo(() => (rule.ok ? T.coveringRules(rule, D.INTENTS, { except: saved ? saved.id : null, members: toPick && toPick.kind !== "builtin" ? toMembers : null, groups: groups.concat(needGroups.map((p) => ({ name: p.name, selector: p.selector }))), servers: D.SERVERS }) : []), [coverKey, D.INTENTS, D.SERVERS, groups]); // Source servers enrolled after a destination's last confirmed rebuild: // Imara does not add them to its firewall by itself (P2.26), so an // existing rule may not cover them there; approving this rule rebuilds it. const newSources = useMemo(() => { if (!covering.length || !fromPick || fromPick.kind === "builtin") return []; const srcs = fromPick.kind === "server" && fromPick.server ? [fromPick.server] : (fromPick.members || []).filter(T.hasApprovedAddress); const out = []; toMembers.forEach((dst) => { const built = T.lastConfirmedBuild(dst, { intents: D.INTENTS }); if (!built) return; srcs.forEach((src) => { const e = Date.parse(src.first_seen || ""); if (isFinite(e) && e > built) out.push({ src, dst }); }); }); return out; }, [covering, coverKey, fromPick ? fromPick.kind + ":" + fromPick.name : "", D.INTENTS, D.SERVERS]); // Ports recently seen in traffic to the destination servers (the traffic // map's records), busiest first: suggested in the service search. const seenPorts = useMemo(() => { const ids = new Set(toMembers.map((m) => m.agent_id)); if (!ids.size) return []; const flows = {}; (D.TOPO_EDGES || []).forEach((e) => { if (ids.has(e.to) && e.port) { const k = (e.proto || "tcp") + "/" + e.port; flows[k] = (flows[k] || 0) + (e.flows || 1); } }); return Object.keys(flows).sort((a, b) => flows[b] - flows[a]).slice(0, 5).map((spec) => ({ spec, flows: flows[spec] })); }, [toPick ? toPick.kind + ":" + toPick.name : "", D.TOPO_EDGES, D.SERVERS]); const fromWorld = from === "world"; const sensitive = specs.filter((p) => T.SENSITIVE.includes(p)); // Compile check (preview saves nothing), debounced while editing. const needKey = needGroups.map((p) => p.name).join(","); useEffect(() => { if (!open || !body) { setCheck({ state: "idle" }); return; } // The compiler cannot check a group that does not exist yet. if (needKey) { setCheck({ state: "later" }); return; } let live = true, again = null; setCheck({ state: "checking" }); const t = setTimeout(async () => { try { const v = await window.ImaraAPI.previewVerdict({ name: effName, body, target_selector: targetSelector }); if (live) setCheck({ state: v && v.error ? "error" : "ok", verdict: v || {} }); } catch (e) { const msg = String((e && e.message) || e); // A group this page just made can take a few seconds to reach the // compiler: check again rather than call the rule unusable. if (live && /not found in registry/i.test(msg) && Date.now() - groupMadeAt.current < 15000) { setCheck({ state: "preparing" }); again = setTimeout(() => live && setCheckRetry((n) => n + 1), 2500); return; } if (live) setCheck({ state: "error", verdict: { error: msg } }); } }, 600); return () => { live = false; clearTimeout(t); if (again) clearTimeout(again); }; }, [open, body, needKey, checkRetry]); if (!open) return null; // Runs one step. live() turns false when the page was closed or reopened // meanwhile, so a late answer never lands on another rule. async function run(label, fn) { const my = session.current; const live = () => session.current === my; setBusy(label); setErr(""); try { return await fn(live); } catch (e) { if (live()) setErr(String((e && e.message) || e)); return null; } finally { if (live()) setBusy(""); } } async function runImpact(live, id, forMeaning) { try { const result = await ops.simulate(id); if (live()) setImpact({ forMeaning, result: result || {} }); } catch (e) { if (live()) setImpact({ error: String((e && e.message) || e) }); } } // Rule text names groups only: a side that names one server needs that // server's one-server group, made here if it does not exist yet. -> // {text, sel, made} or null when the page closed meanwhile. // Every server choice is checked again with fresh data, including ones // whose group already exists (its name must still belong to it alone); // an automatic group is made if no stored group has its conditions. const prepareGroups = async (live) => { let fName = fromName, tName = toName, sel = targetSelector; const made = []; const isMade = (p) => p && ((p.kind === "server" && p.server) || (p.kind === "auto" && p.needsGroup)); const picks = [fromPick, toPick].filter((p, i, a) => isMade(p) && a.findIndex((q) => isMade(q) && pickKey(q) === pickKey(p)) === i); try { for (const p of picks) { const g = p.kind === "server" ? await ops.ensureServerGroup(p.server) : await ops.ensureAutoGroup(p.selector, p.description); if (g.created) { made.push(g); madeHere.current.push(g); groupMadeAt.current = Date.now(); } // The page closed meanwhile: nothing will name a group made here. if (!live()) { made.forEach((m) => ops.dropUnusedGroup && ops.dropUnusedGroup(m)); return null; } if (isMade(fromPick) && pickKey(fromPick) === pickKey(p)) fName = g.name; if (isMade(toPick) && pickKey(toPick) === pickKey(p)) { tName = g.name; sel = g.selector || sel; } } } catch (e) { made.forEach((m) => ops.dropUnusedGroup && ops.dropUnusedGroup(m)); throw e; } return { text: makeBody(fName, tName), sel, made }; }; // A save that failed after groups were made here: a group the compiler did // not see yet is kept for the next try; otherwise unused ones are removed. const saveFailed = (e, made) => { const msg = String((e && e.message) || e); if (made.length && /not found in registry/i.test(msg)) throw new Error(`The new group for ${T.joinList(made.map((g) => g.selector && g.selector._hostname ? g.selector._hostname : T.groupName(g.name)))} is still being prepared. Nothing was saved; try again in a few seconds.`); made.forEach((g) => ops.dropUnusedGroup && ops.dropUnusedGroup(g)); throw e; }; const saveDraft = () => run("save", async (live) => { const prep = await prepareGroups(live); if (!prep) return; const text = prep.text; let res; try { res = await ops.saveDraft({ name: effName, body: text, target_selector: prep.sel, approvals }); } catch (e) { saveFailed(e, prep.made); } const row = res.row; if (!live()) { toast(`Draft "${T.ruleTitle(row)}" saved (Rules, Drafts).`, "accent"); return; } setSaved(row); setSavedBody(row.body || text); setCreatedHere(true); setImpact(null); if (row.required_approvers) setApprovals(row.required_approvers); toast(res.warning || "Saved as draft. Nothing changes on servers until it is approved.", res.warning ? "warn" : "accent"); setBusy("impact"); await runImpact(live, row.id, ruleMeaning(row.body || text)); }); // The server saved exactly `body` once the update succeeded; if the rule // could not be re-read, sending stays blocked until it is reopened. const saveChanges = (confirmed) => { if (!confirmed && saved && saved.approval_state === "requested") { setConfirmReplace(true); return; } setConfirmReplace(false); return saveChangesNow(); }; const saveChangesNow = () => run("save", async (live) => { const prep = await prepareGroups(live); if (!prep) return; const text = prep.text; let res; const expect = { state: saved.approval_state, approvers: saved.approval_state === "requested" ? Number(saved.approvers_count || 0) : null }; try { res = await ops.updateDraft(saved.id, text, approvals, saved.version, expect); } catch (e) { // Sent for approval meanwhile: show it, so the next save asks first. if (e && e.code === "STATE_CHANGED" && e.fresh && live()) setSaved((cur) => (cur && cur.id === e.fresh.id ? { ...cur, approval_state: e.fresh.approval_state, approvers_count: e.fresh.approvers_count || 0 } : cur)); saveFailed(e, prep.made); } if (!live()) return; const row = res.row ? res.row : { ...saved, body: text, approval_state: "draft", uncertain: true }; setSaved(row); setSavedBody(res.row ? (row.body || text) : text); setImpact(null); if (res.row && row.required_approvers) setApprovals(row.required_approvers); toast(res.warning || `Saved as draft version ${row.version || ""}. Servers keep the approved version until this one is approved.`, res.warning ? "warn" : "accent"); if (row.uncertain) return; setBusy("impact"); await runImpact(live, row.id, ruleMeaning(row.body || text)); }); const checkImpact = () => run("impact", (live) => runImpact(live, saved.id, savedMeaning)); // The approvals needed are stored on the rule as soon as it is a draft. // Lowering is possible only on a new draft made on this page and never // sent (the same as choosing the number before saving); otherwise the // saved number is the minimum. const canLower = !!(saved && createdHere && !attempted && !Number(saved.approved_version || 0)); // The saved rule as the console has it now (the list may hold newer // details, such as where it is confirmed), and what approving it does. const current = saved ? ((D.INTENTS || []).find((i) => i.id === saved.id && Number(i.version) === Number(saved.version)) || saved) : null; const changeCtx = { groups, servers: D.SERVERS, byId: D.byId || {}, intents: D.INTENTS, incomplete: D.POLICY_INCOMPLETE }; // Worked out once per saved rule and data; not while the form has unsaved // changes (nothing can be sent or applied then). const afterLines = current && !saved.uncertain && !saved.stale && !dirty ? panelMemo(current, "after", [D.INTENTS, D.GROUPS, D.SERVERS, D.POLICY_INCOMPLETE, D.FW_VERSION, groups, Math.floor(T.serverNow() / 60000)], () => T.approvalLines(current, changeCtx)) : []; // A version that turns incoming protection on or off (for example a // draft whose approved version asks for it) needs two approvals. When // the approved text could not be loaded the console cannot tell, and // treats it as a switch. const sw = current ? T.protectionSwitch(current, current.body) : { sw: false, kind: "", why: "" }; const switchState = sw.sw; const cav = current ? Number(current.approved_version || 0) : 0; const switchText = !sw.sw ? "" : sw.kind === "off" && cav ? `Approving turns default deny off on its servers: approved version ${cav} sets it, this version does not.` : sw.kind === "on" && cav ? `Approving turns default deny on for its servers: approved version ${cav} does not set it, this version does.` : sw.why; const minNow = Math.max(saved && !canLower ? Math.max(minApprovals, saved.required_approvers || 1) : minApprovals, switchState !== false ? 2 : 1); // The page never shows fewer approvals than the rule needs. const appr = Math.max(approvals, minNow); const changeApprovals = (n) => { if (n === approvals || n < minNow) return; setConfirmApply(false); if (!saved || !["draft", "rejected"].includes(saved.approval_state)) { setApprovals(n); return; } const prev = approvals; setApprovals(n); run("approvals", async (live) => { try { const row = await ops.setApprovals(saved.id, n, saved.required_approvers || 1, canLower, saved.version); if (!live() || !row) return; // Someone saved a newer version in between: never adopt text the // page does not show. if (Number(row.version) !== Number(saved.version)) { setSaved({ ...saved, stale: true }); return; } setSaved(row); setApprovals(row.required_approvers || n); } catch (e) { if (live()) { setApprovals(prev); reread(live); } throw e; } }); }; // After a failed step: if the rule moved to another version, stop; the page // must not quietly adopt text it does not show. Otherwise take the row. const reread = (live) => ops.reload(saved.id).then((row) => { if (!live() || !row) return; if (Number(row.version) !== Number(saved.version)) setSaved({ ...saved, stale: true }); else { setSaved(row); if (row.required_approvers) setApprovals(Math.max(row.required_approvers, minApprovals)); } }).catch(() => {}); const send = () => run("send", async (live) => { if (!canSend) return; setAttempted(true); try { const row = await ops.send(saved.id, appr, saved.required_approvers || 1, saved.version); toast(sentText(row, appr), "ok"); if (live()) { onClose(); ops.open(row || saved); } } catch (e) { reread(live); throw e; } }); // shown: the lines the confirmation showed; the operation checks them // again with fresh data and refuses if they changed. const applyNow = () => run("apply", async (live) => { const shown = confirmApply && confirmApply.shown ? confirmApply.shown : []; setConfirmApply(false); if (!canSend || !canApplyNow) return; setAttempted(true); try { const row = await ops.applyNow(saved.id, saved.required_approvers || 1, saved.version, T.lineKeys(shown)); toast(applyOutcome(row), row && row.status === "error" ? "warn" : "ok"); if (live()) { onClose(); ops.open(row || saved); } } catch (e) { reread(live); throw e; } }); const sentText = (row, asked) => { const n = (row && row.required_approvers) || asked; if (row && row.read_back_failed) return "Sent for approval, but the rule could not be read back. Open it from the Rules page."; return n >= 2 ? `Sent for approval: ${n} approvals needed.` : "Sent for approval: 1 approval needed (can be you)."; }; // A new rule in one step: saved as a draft (the Imara server checks it // then) and sent for approval at once, unless what approving it does shows // that this version cannot work (the draft then stays, with the reason). const saveAndSend = () => run("send", async (live) => { const prep = await prepareGroups(live); if (!prep) return; let res; try { res = await ops.saveDraft({ name: effName, body: prep.text, target_selector: prep.sel, approvals }); } catch (e) { saveFailed(e, prep.made); } const row = res.row; if (!live()) { toast(`Draft "${T.ruleTitle(row)}" saved but not sent (Rules, Drafts).`, "warn"); return; } setSaved(row); setSavedBody(row.body || prep.text); setCreatedHere(true); const DD = window.MS_DATA; const stop = T.approvalLines(row, { groups: DD.GROUPS || [], servers: DD.SERVERS, byId: DD.byId || {}, intents: DD.INTENTS, incomplete: DD.POLICY_INCOMPLETE }).find((l) => l.cannotWork); if (stop) throw new Error(`Saved as draft, not sent: ${stop.text}`); setAttempted(true); let sent; try { sent = await ops.send(row.id, appr, row.required_approvers || 1, row.version); } catch (e) { // The draft stays saved; show it as the Imara server has it now. The // request may have reached the server before the error. ops.reload(row.id).then((r) => { if (live() && r && Number(r.version) === Number(row.version)) setSaved(r); }).catch(() => {}); throw new Error(`Saved as draft, but sending for approval did not complete (${String((e && e.message) || e)}). It may already be waiting for approval: check the Rules page before sending again.`); } toast(sentText(sent, appr), "ok"); if (live()) { onClose(); ops.open(sent || row); } }); // With one approval: saved, then the footer shows what applying does and // asks to confirm. Worked out with the data as it is after saving, so a // one-server group made just now is included (the apply checks the same // lines again). const saveThenApply = () => run("applysave", async (live) => { const prep = await prepareGroups(live); if (!prep) return; let res; try { res = await ops.saveDraft({ name: effName, body: prep.text, target_selector: prep.sel, approvals }); } catch (e) { saveFailed(e, prep.made); } const row = res.row; if (!live()) { toast(`Draft "${T.ruleTitle(row)}" saved but not applied (Rules, Drafts).`, "warn"); return; } setSaved(row); setSavedBody(row.body || prep.text); setCreatedHere(true); setImpact(null); if (row.required_approvers) setApprovals(row.required_approvers); const DD = window.MS_DATA; const nowCtx = { groups: DD.GROUPS || [], servers: DD.SERVERS, byId: DD.byId || {}, intents: DD.INTENTS, incomplete: DD.POLICY_INCOMPLETE }; setConfirmApply({ shown: T.approvalLines(row, nowCtx), exposed: T.internetExposure(T.parseRule(row.body || prep.text)) }); }); const discard = () => run("discard", async (live) => { await ops.discard(saved.id); // One-server groups made for this draft go too, unless something else // names them meanwhile (checked again by dropUnusedGroup). madeHere.current.forEach((g) => ops.dropUnusedGroup && ops.dropUnusedGroup(g)); madeHere.current = []; toast("Draft deleted.", "warn"); if (live()) onClose(); }); // A draft may be deleted from here only while it is the unsent draft this // page created; the server checks again before deleting. const canDelete = !!(saved && createdHere && !attempted && saved.approval_state === "draft" && !Number(saved.approved_version || 0)); const requestClose = () => { if (leaving) { setLeaving(""); return; } if (busy && busy !== "impact") { setLeaving("busy"); return; } if (confirmApply) { setConfirmApply(false); return; } if (confirmReplace) { setConfirmReplace(false); return; } if (editing && !editable.ok) { onClose(); return; } if (!saved && hasInput) { setLeaving("unsaved"); return; } if (saved && dirty && hasInput) { setLeaving("changes"); return; } if (canDelete) { setLeaving("draft"); return; } onClose(); }; const lockTo = editing || !!saved; const canSend = !!saved && complete && !dirty && !saved.uncertain && !saved.stale && ["draft", "rejected"].includes(saved.approval_state); // A text that fails the check, or names a group the Imara server cannot // use, cannot be applied: its servers' firewall could not be built. const blockedText = current ? T.unusableGroupRefs(current, changeCtx, current.body).filter((b) => b.stuck) : []; const cannotWork = afterLines.find((l) => l.cannotWork); const applyBlocked = check.state === "error" ? "Fails the check (see Check under More options)." : cannotWork ? cannotWork.text : blockedText.length ? `${T.groupName(blockedText[0].group)}: ${blockedText[0].why}. This version cannot work now.` : ""; const canApplyNow = appr === 1; // The group choices leave out one-server groups (they show as servers), // unless one is the current choice and has not exactly one server. const listGroups = (cur) => sortedGroups.filter((g) => g.auto || !T.isServerGroup(g) || cur === g.name); // One-server groups made on save count as groups already, so the // summary names the server. const ruleCtx = { groups: groups.concat(needGroups.map((p) => ({ name: p.name, selector: p.selector }))), servers: D.SERVERS }; const summaryText = (() => { if (rule.ok) return T.describeRule(rule, ruleCtx).text; if (pickProblem) return pickProblem; if (!picked) return "Choose a source, a destination and services."; return nameTaken ? "A rule with this name already exists: change the name." : "Invalid name: use lowercase letters, digits and hyphens, up to 48 characters."; })(); const checkText = (() => { if (!body) return { tone: "neutral", text: picked ? "Fix the name to check the rule." : "Checked once the rule is complete." }; if (check.state === "checking") return { tone: "neutral", text: "Checking…" }; if (check.state === "preparing") return { tone: "neutral", text: "Preparing the new group; checking again in a moment…" }; if (check.state === "later") return { tone: "neutral", text: `Checked on save, after Imara creates ${T.joinList(needGroups.map((p) => (p.kind === "server" ? `a group containing only ${p.title}` : `the group ${p.title}`)))}.` }; if (check.state === "error") return { tone: "bad", text: plainCheckError(check.verdict && check.verdict.error) || "Invalid rule. Details under Technical details." }; if (check.state === "ok") { const v = check.verdict || {}; if (v.guard_passed) return { tone: "ok", text: "Valid. Imara can still manage these servers." }; if (v.guard_checked === false) return { tone: "neutral", text: "Valid. This Imara server does not check whether Imara keeps its connection to these servers." }; return { tone: "bad", text: "Blocked for safety: this would cut these servers off from Imara." }; } return { tone: "neutral", text: "" }; })(); // The choices each side offers, one line each, in this order: address // ranges (source only), usable groups by kind (DC or DR first, then // subnets, then application tiers, then the rest), single servers, and // last the groups that cannot be used yet. const groupOption = (g, side) => { const gi = groupInfo[g.name]; const reason = side === "from" ? gi.fromReason : gi.toReason; // As a source, servers without an approved address are left out. const missing = side === "from" && !reason ? gi.members.filter((m) => !T.hasApprovedAddress(m)).length : 0; return { value: g.name, section: reason ? "Not usable yet" : kindTitles[T.groupKind(g)], title: T.groupName(g.name), sub: [T.plural(gi.members.length, "server") + (missing ? `, ${missing} without an approved address` : ""), g.description].filter(Boolean).join(" · "), search: [g.name, g.description || "", T.selectorText(g.selector || {})].join(" ").toLowerCase(), reason, reasonShort: !gi.members.length ? "no servers yet" : /no conditions/.test(reason || "") ? "every server: too wide" : "needs an approved address" }; }; // An automatic group: like a stored one, made when the rule is saved. const autoOption = (a, side) => { const members = T.groupMembers(a.selector, D.SERVERS); const reason = !members.length ? "No servers in it now." : !members.some(T.hasApprovedAddress) ? "None of its servers has an approved IP address yet, so rules cannot use it. Approve addresses on each server's page." : ""; const missing = side === "from" && !reason ? members.filter((m) => !T.hasApprovedAddress(m)).length : 0; return { value: a.id, section: reason ? "Not usable yet" : kindTitles[T.groupKind({ selector: a.selector })], title: T.groupName(a.name), sub: [T.plural(members.length, "server") + (missing ? `, ${missing} without an approved address` : ""), a.description].join(" · "), search: [a.name, a.description, String(a.value || "")].join(" ").toLowerCase(), reason, reasonShort: !members.length ? "no servers yet" : "needs an approved address" }; }; const groupOptions = (side, cur) => { const all = listGroups(cur).map((g) => (g.auto ? autoOption(g.auto, side) : groupOption(g, side))); if (cur && cur.startsWith("auto:") && !all.some((o) => o.value === cur)) { const sel = T.parseAutoId(cur); if (sel && !groups.some((g) => T.sameSelector(g.selector || {}, sel))) all.unshift(autoOption(T.autoGroupFor(sel, groups), side)); } return { ok: all.filter((o) => !o.reason), no: all.filter((o) => o.reason) }; }; const fromGroups = groupOptions("from", from), toGroups = groupOptions("to", to); const fromOptions = [ // Searched by name only: their descriptions mention words such as // "internet" or "partner" that also name groups. ...T.PAGE_SOURCES.map((id) => ({ value: id, section: "Address ranges", title: T.BUILTIN_GROUPS[id].title, sub: T.BUILTIN_GROUPS[id].hint, search: T.BUILTIN_GROUPS[id].title.toLowerCase() })), ...fromGroups.ok, ...serverOptions, ...fromGroups.no, ]; const toOptions = [...toGroups.ok, ...serverOptions, ...toGroups.no]; // One line about the chosen side. A single server is found by its name, // and a rename means different things on each side (behind the "?"). const pickDetail = (p, side) => { if (!p) return ""; if (p.kind === "builtin") return T.BUILTIN_GROUPS[p.name].hint; if (p.kind === "server") { const ips = (p.server && p.server.approved_ips) || []; return ( {ips.length ? `Matched by its approved IP address${ips.length === 1 ? "" : "es"} ${ips.slice(0, 2).join(", ")}${ips.length > 2 ? ` and ${ips.length - 2} more` : ""}.` : ""} The rule finds the server by name. {side === "from" ? "If it is renamed, the destination servers get no firewall changes until the rule is fixed. Any server later given this name would be allowed to connect." : "If it is renamed, the rule stops applying to it. Any server later given this name would get it."} ); } return [memberLine(p.members), T.selectorText(p.selector || {}).replace(/^servers with /, "")].filter(Boolean).join(" · "); }; const question = (title, hint) => (
{title} {hint && {hint}}
); // Source servers in the chosen group that have no approved address: the // rule cannot let them connect until their addresses are approved. const leftOut = fromPick && (fromPick.kind === "group" || fromPick.kind === "auto") ? (fromPick.members || []).filter((m) => !T.hasApprovedAddress(m)) : []; const leftOutText = leftOut.length && leftOut.length < (fromPick.members || []).length ? `${leftOut.length} of ${fromPick.members.length} servers in ${fromPick.title} ${leftOut.length === 1 ? "has" : "have"} no approved IP address, so this rule does not allow ${leftOut.length === 1 ? "it" : "them"} yet: ${T.hostList(leftOut, 3)}.` : ""; // What needs attention before sending, shown next to the buttons too // (the summary can be below the fold). const attention = [ pickProblem && { tone: "bad", text: "Cannot be used" }, checkText.tone === "bad" && { tone: "bad", text: "Fails the check" }, fromWorld && sensitive.length > 0 && { tone: "warn", text: "Open to the internet" }, covering.length > 0 && { tone: "warn", text: "Another rule already allows this" }, leftOutText && { tone: "warn", text: `${leftOut.length} source server${leftOut.length === 1 ? "" : "s"} left out` }, ].filter(Boolean); return (
{editing ? "edit rule" : "new rule"}
{editing ? T.ruleTitle(editRow) : "Allow inbound connections"}
{editing && !editable.ok ? (
This rule cannot be edited here
This page would drop part of it: {T.joinList(editable.reasons)}. Edit it as code instead (rule panel, Technical details).
) : (
{question("Source")} {fromPick &&
{pickDetail(fromPick, "from")}
}
{question("Destination", lockTo ? "fixed for this rule" : "fixed once saved")} {lockTo ? (
{toLabel} {!dirty && !busy && !confirmApply && !canDelete && ops.newRule && }
) : } {toPick &&
{pickDetail(toPick, "to")}
}
{question("Services")} setSpecs((cur) => Array.from(new Set([...cur, ...ports])))} /> {specs.length > 0 && (
{specs.map((p) => ( {T.serviceName(p)} ))}
)}
{/* what the rule does, in one place */}
{summaryText}
{leftOutText &&
{leftOutText}
} {to && toMembers.length > 0 && rule.ok && (
{toPick && toPick.kind === "server" && toMembers.length === 1 ? `Applies to ${toMembers[0].hostname} only.` : `Applies to ${T.hostList(toMembers)} and any future server with ${T.selectorText(targetSelector).replace(/^servers with /, "")}.`}
)} {to && } {covering.length > 0 && (
{T.joinList(covering.map((c) => `${T.ruleLabel(c.name)} (approved version ${c.version}, confirmed on ${c.confirmed} of ${c.total} server${c.total === 1 ? "" : "s"})`))} already {covering.length === 1 ? "allows" : "allow"} this.{newSources.length ? (() => { const dsts = Array.from(new Set(newSources.map((x) => x.dst))), srcs = Array.from(new Set(newSources.map((x) => x.src))); return ` But ${T.hostList(srcs, 3)} ${srcs.length === 1 ? "was" : "were"} enrolled after the last confirmed firewall rebuild on ${T.hostList(dsts, 3)}. The existing rule may not cover ${srcs.length === 1 ? "it" : "them"} there yet; approving this rule rebuilds ${dsts.length === 1 ? "that firewall" : "those firewalls"}.`; })() : " You may not need this rule."}
)} {fromWorld && sensitive.length > 0 &&
Opens {T.servicesText(sensitive)} to any address, including the internet. Rarely intended.
} {checkText.tone === "bad" &&
{checkText.text}{check.state === "error" && }
} {saved && !dirty && switchText &&
{switchText} 2 approvals needed.
}
{/* more: name, approvals, the check, impact, technical details */}
{more && (
{ setNameTouched(true); setName(e.target.value.toLowerCase()); }} />
Approvals
{appr >= 2 ? "Two people other than you must approve." : "Any console user can approve, including you."}{app && !editing ? ` Default from application ${app.name}.` : ""}
Check
{checkText.text}
{saved && !dirty && afterLines.length > 0 && (
When approved
{afterLines.map((l, i) =>
{l.text}
)}
)} {saved && (
Impact on recent connections
{!impact &&
{busy === "impact" ? "Checking the last 14 days of connections…" : dirty ? "Save to run the check again." : "Not checked yet."}
}
)}
{tech && (
Rule text (YAML)
{body || "(incomplete)"}
{check.state === "error" &&
{check.verdict && check.verdict.error}
} {check.state === "ok" && check.verdict && check.verdict.artifact && (
Generated firewall rules (nftables)
{typeof highlightNft === "function" ? highlightNft(check.verdict.artifact) : check.verdict.artifact}
)}
)}
)}
)}
{err &&
{plainCheckError(err) || err}
} {saved && saved.uncertain &&
Saved, but the rule could not be read back. Reopen it from the Rules page before sending it for approval.
} {saved && saved.stale &&
Someone saved a newer version of this rule while this page was open. Close it and reopen the rule from the Rules page.
} {leaving ? (
{{ unsaved: "This rule is not saved. Leave without saving?", changes: "Your latest changes are not saved. Leave anyway? The saved version stays.", draft: "Keep the draft to finish later (Rules, Drafts), or delete it?", busy: "Still working on this rule. If you leave now, it may still be saved as a draft (Rules, Drafts).", }[leaving]}
{leaving === "draft" ? ( ) : }
) : confirmReplace ? (
Version {saved && saved.version} is waiting for approval{saved && saved.approvers_count ? ` (${saved.approvers_count} approval${saved.approvers_count === 1 ? "" : "s"} so far)` : ""}. Saving creates a new draft and cancels the request{saved && saved.approvers_count ? " and its approvals" : ""}; send the new version again.
) : confirmApply ? (
Apply this rule to {T.plural(toMembers.length, "server")} now, with no other reviewer? {(confirmApply.exposed || []).length > 0 &&
Opens {T.servicesText(confirmApply.exposed)} to any address, including the internet.
}
{(confirmApply.shown || []).map((l, i) =>
{l.text}
)}
{(() => { const stop = (confirmApply.shown || []).find((l) => l.cannotWork); const why = stop ? stop.text : !canApplyNow ? "This rule now needs 2 approvals: send it for approval instead." : canSend ? "" : "The form changed: save it first"; return ; })()}
) : ( {attention.length > 0 && !(editing && !editable.ok) && (
{attention.map((a) => ( ))}
)}
{!(editing && !editable.ok) && (
{!saved ? ( {appr === 1 ? : } ) : dirty ? ( ) : ( {canApplyNow && } )}
)}
)}
); } window.NewRulePage = NewRulePage; window.ImpactResult = ImpactResult; window.ProtectionStatement = ProtectionStatement; // ============================================================================ // Turn on default deny for a group (plan P1.6). Creates the managed // rule protect-in-: // // policy: protect-in- // description: "..." // direction: ingress // from: {group: } // to: {group: } // allow: ["tcp/22"] // deny: all // // sent to the group's servers, needing two approvals, and sends it for // approval. Admin access is required (there is no "none": the policy needs // at least one allowed port, and SSH keeps the servers manageable). There is // no one-click removal; turning protection off is an ordinary rule change. // ============================================================================ function protectRuleName(group, taken) { let base = ("protect-in-" + rulePart(group)).slice(0, 44).replace(/-+$/, ""); let name = base, n = 2; while (taken.has(name)) name = `${base}-${n++}`; return name; } function ProtectionRequestPage({ open, group, onClose, toast, ops }) { const D = window.MS_DATA; const T = window.ImaraTerms; const [admin, setAdmin] = useState(""); const [showText, setShowText] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const session = useRef(0); // Nothing is preselected (D4): the operator chooses where SSH stays open. useEffect(() => { session.current++; if (open) { setAdmin(""); setBusy(false); setErr(""); } return () => { session.current++; }; }, [open, group && group.name]); if (!open || !group) return null; const members = T.groupMembers(group.selector || {}, D.SERVERS); const gp = T.groupProtection(members, { intents: D.INTENTS, groups: D.GROUPS, servers: D.SERVERS, incomplete: D.POLICY_INCOMPLETE }); const taken = new Set((D.INTENTS || []).map((i) => i.name)); const existing = (D.INTENTS || []).filter((i) => /^protect-in-/.test(i.name) && T.sameSelector(i.target_selector || {}, group.selector || {}) && !(Number(i.approved_version || 0) > 0 && i.approval_state === "approved")); const name = protectRuleName(group.name, taken); // The protected group (or a group inside it) as the SSH source would allow // SSH only between the protected servers themselves, so it is not offered. const memberIds = new Set(members.map((s) => s.agent_id)); const adminFixed = [{ id: "world", title: "Anywhere", hint: T.BUILTIN_GROUPS.world.hint }, { id: "private-rfc1918", title: "Private addresses", hint: T.BUILTIN_GROUPS["private-rfc1918"].hint }]; const adminGroups = T.sortGroups(D.GROUPS || []).filter((g) => !T.isServerGroup(g)).filter((g) => { const gm = T.groupMembers(g.selector || {}, D.SERVERS); return !T.isEmptySelector(g.selector) && gm.some(T.hasApprovedAddress) && !gm.every((s) => memberIds.has(s.agent_id)); }); // Rules that keep working: each member's own version (a trial keeps the // stable version on the other servers), not withdrawn, not ended. const today = T.serverToday(); const allowRules = []; (D.INTENTS || []).forEach((i) => { // One entry per version actually meant for these servers (a trial keeps // the stable version on the other servers), with where it is confirmed. const byVersion = {}; members.forEach((s) => { const w = T.expectedVersion(i, s.agent_id); if (!w || !T.targetsServer(i, s)) return; const row = T.targetRow(i, s.agent_id); const ok = row ? row.status === "applied" && Number(row.version) === w : i.target_agent_id === s.agent_id && i.status === "applied"; (byVersion[w] = byVersion[w] || { servers: [], confirmed: 0 }).servers.push(s.hostname); if (ok) byVersion[w].confirmed++; }); Object.keys(byVersion).forEach((w) => { const r = T.parseRule(T.bodyOfVersion(i, Number(w)) || ""); if (r.ok && r.direction === "ingress" && !(r.expires && r.expires < today)) { const e = byVersion[w]; allowRules.push({ key: i.name + "@" + w, name: `${T.ruleTitle(i)} (version ${w} on ${T.hostList(e.servers, 3)}; confirmed on ${e.confirmed} of ${e.servers.length})`, d: T.describeRule(r, { groups: D.GROUPS, servers: D.SERVERS }) }); } }); }); // One server as the SSH source ("server:"): named through its // one-server group, made when the request is sent if it does not exist. const adminServer = admin.startsWith("server:") ? (D.byId || {})[admin.slice(7)] : null; const adminGroup = adminServer ? T.serverGroupFor(adminServer, D.GROUPS || []) : null; const adminName = adminServer ? (adminGroup ? adminGroup.name : T.serverGroupName(adminServer, (D.GROUPS || []).concat(group.pending || group.auto ? [{ name: group.name }] : []))) : admin; const adminServerReason = (s) => memberIds.has(s.agent_id) ? "It is one of the servers being protected." : T.serverNameIssue(s, D.SERVERS) || (!T.hasApprovedAddress(s) ? "No approved IP address yet." : ""); // Where SSH stays open from, as one searchable choice (as on the rule // page): address ranges, groups by kind, then single servers. const adminKindTitles = Object.fromEntries(T.GROUP_KINDS.map((k) => [k, T.groupKindTitle(k, adminGroups)])); const adminOptions = [ ...adminFixed.map((c) => ({ value: c.id, section: "Address ranges", title: c.title, sub: c.hint, search: c.title.toLowerCase() })), ...adminGroups.map((g) => ({ value: g.name, section: adminKindTitles[T.groupKind(g)], title: T.groupName(g.name), sub: [memberLine(T.groupMembers(g.selector, D.SERVERS)), g.description].filter(Boolean).join(" · "), search: [g.name, g.description || ""].join(" ").toLowerCase() })), ...(D.SERVERS || []).slice().sort((a, b) => String(a.hostname).localeCompare(String(b.hostname))).map((sv) => { const why = adminServerReason(sv); return { value: "server:" + sv.agent_id, section: "Single servers (for example a jump host)", title: sv.hostname, sub: (sv.approved_ips || [])[0] || "", search: [sv.hostname || ""].concat(sv.approved_ips || []).join(" ").toLowerCase(), reason: why, reasonShort: !why ? "" : /being protected/.test(why) ? "being protected" : /same name/.test(why) ? "same name as another" : /approved IP/.test(why) ? "needs an approved address" : "cannot be used" }; }), ]; // Recorded connections into these servers, judged as if protection were // on, with the SSH rule this request adds (once a source is chosen). // Groups made only when sending count already. const ids = new Set(members.map((s) => s.agent_id)); const adminRule = admin ? { id: -1, name, version: 1, approved_version: 1, status: "applied", approval_state: "approved", canary_state: "none", fresh: true, target_selector: group.selector || {}, body: `policy: ${name}\nfrom: {group: ${JSON.stringify(adminName)}}\nto: {group: ${JSON.stringify(group.name)}}\nallow: ["tcp/22"]\n`, targets: members.map((s) => ({ agent_id: s.agent_id, status: "applied", version: 1 })) } : null; const pendingGroups = [group.pending || group.auto ? group : null, adminServer && !adminGroup ? { name: adminName, selector: T.serverSelector(adminServer) } : null].filter(Boolean); const edgeKey = (e) => e.from + ">" + e.to + ":" + e.port; // Only connections into the servers being protected are judged. const intoThese = (D.TOPO_EDGES || []).filter((e) => ids.has(e.to)); const blockedNow = new Set(T.edgesWithVerdicts({ ...D, TOPO_EDGES: intoThese }).filter((e) => e.verdict === "blocked").map(edgeKey)); const judged = T.edgesWithVerdicts({ ...D, TOPO_EDGES: intoThese, GROUPS: (D.GROUPS || []).concat(pendingGroups), INTENTS: (D.INTENTS || []).concat(adminRule ? [adminRule] : []) }, { assumeProtection: "on" }).filter((e) => !blockedNow.has(edgeKey(e))); const wouldBlock = judged.filter((e) => e.verdict === "blocked"); const mayBlock = judged.filter((e) => e.verdict === "unknown"); const adminText = !admin ? "the source you choose" : adminServer ? "the server " + adminServer.hostname : admin === "world" ? "any address" : admin === "private-rfc1918" ? "any private address" : T.groupName(admin); // A one-server group is shown as its server; one that now holds several // servers (sharing a name) is said so. const one = T.isServerGroup(group) && members.length <= 1; const shared = T.isServerGroup(group) && members.length > 1; const label = T.isServerGroup(group) ? group.selector._hostname : T.groupName(group.name); const these = one ? "this server" : "these servers"; const makeBody = (ruleName, groupName, sourceName = adminName) => [ `policy: ${ruleName}`, `description: ${JSON.stringify(`Default Deny For ${label}`)}`, "direction: ingress", `from: {group: ${JSON.stringify(sourceName)}}`, `to: {group: ${JSON.stringify(groupName)}}`, `allow: ["tcp/22"]`, "deny: all", ].join("\n") + "\n"; // A one-server group that does not exist yet (group.pending) is made only // now; if the request then fails, a group made here is removed again // unless something names it. const submit = async () => { const my = session.current; setBusy(true); setErr(""); let made = []; try { // One-server groups (new or existing) are checked again with fresh data. const protServer = group.pending ? group.server : T.isServerGroup(group) && members.length === 1 ? members[0] : null; const g = protServer ? await ops.ensureServerGroup(protServer) : group.auto ? await ops.ensureAutoGroup(group.selector, group.description) : group; if (g.created) made.push(g); const ag = adminServer ? await ops.ensureServerGroup(adminServer) : null; if (ag && ag.created) made.push(ag); const ruleName = group.pending || group.auto ? protectRuleName(g.name, taken) : name; const row = await ops.requestProtection({ name: ruleName, body: makeBody(ruleName, g.name, ag ? ag.name : adminName), target_selector: g.selector || {} }); made = []; toast(`Sent for approval as ${ruleName}: 2 approvals needed. Nothing changes on servers until then.`, "ok"); if (session.current === my) { onClose(); ops.open(row); } } catch (e) { if (ops.dropUnusedGroup) made.forEach((m) => ops.dropUnusedGroup(m)); if (session.current === my) setErr(String((e && e.message) || e)); } finally { if (session.current === my) setBusy(false); } }; return ( { if (!busy) onClose(); }} width={760}>
turn on default deny{one ? " for this server" : shared ? ` for the ${members.length} servers named` : ""}
{label}
Once approved: all inbound traffic to {these} that no rule allows is blocked. Existing allow rules keep working.
{shared &&
{T.plural(members.length, "server")} are named {label}, so this applies to all of them. Rename one first if you meant only one.
}
Servers ({members.length})
{members.slice(0, 12).map((s) => { const st = T.protectionNow(s, D).state; return
{s.hostname}{T.protectionWord(st)}
; })} {members.length > 12 &&
and {(members.length - 12).toLocaleString("en-US")} more
} {gp.state === "on" &&
Default deny is already on for {one ? "it" : "all of them"}; this adds another rule that sets it.
}
Keep SSH open from Required, so you can still log in. Pick the narrowest source that includes where your admins connect from. {one ? "Groups holding only this server are not offered: SSH would then be allowed only from the server itself." : `${label} itself is not offered: SSH would then be allowed only between its own servers.`}
Only TCP 22 stays open. If {these} {one ? "uses" : "use"} another port for remote access, allow it with a rule first. Otherwise you lose access once this is approved.
{adminServer &&
Matched by its approved IP address{(adminServer.approved_ips || []).length === 1 ? "" : "es"} {(adminServer.approved_ips || []).slice(0, 2).join(", ")}.The rule finds the server by name. If it is renamed, the protected servers get no firewall changes until the rule is fixed. Any server later given this name would be allowed to connect.
}
Existing allow rules ({allowRules.length})
{allowRules.slice(0, 12).map((x) =>
{x.name}: {x.d.text}
)} {allowRules.length > 12 &&
and {allowRules.length - 12} more; each server's panel lists them all.
} {allowRules.length === 0 &&
{D.POLICY_INCOMPLETE ? "Some rules did not load: this list may be incomplete." : `No approved rule allows inbound traffic to ${these} yet. Only SSH from ${adminText} would be allowed.`}
}
Connections that would be blocked

Only the latest recorded TCP connections between your servers are checked. A rare job (a nightly backup, for example) may be missing.

External connections and UDP traffic such as DNS are not listed either. Allow what you need with rules before this is approved.

{wouldBlock.length ? (D.byId[id] || {}).hostname || id} /> :
None among the latest {T.plural(D.FLOWS_COUNT || 0, "connection")}{D.FLOWS_SINCE ? (() => { const m = Math.max(1, Math.round((T.serverNow() - Date.parse(D.FLOWS_SINCE)) / 60000)); return `, covering only the last ${T.plural(m, "minute")}`; })() : ""}.
} {mayBlock.length > 0 && (
May be blocked, cannot confirm ({mayBlock.length.toLocaleString("en-US")}):
(D.byId[id] || {}).hostname || id} />
)} {blockedNow.size > 0 &&
Not listed: {T.plural(blockedNow.size, "kind")} of connection already blocked today.
}
{existing.length > 0 &&
Already requested, not approved yet: {T.joinList(existing.map((i) => `"${T.ruleTitle(i)}"`))}. Check {existing.length === 1 ? "it" : "them"} on the Rules page before requesting again.
} {(() => { const asked = members.filter((s) => T.protectionNow(s, D).state !== "on" && T.protectionNow(s, D).contributors.some((c) => c.asks === true)); return asked.length ?
An approved rule already sets default deny on {T.joinList(asked.map((s) => s.hostname))}, but it is not in force there. Open the server to see why before requesting again.
: null; })()}
{showText &&
{admin ? makeBody(name, group.name) : "(choose the SSH source first)"}
} {T.isServerGroup(group) &&
{group.pending ? `Sending this also creates a group containing only ${label}. ` : ""}The rule finds the server by name. If it is renamed, the rule stops applying and default deny turns off there, without any approval.
}
{err &&
{plainCheckError(err) || err}
}
); } window.ProtectionRequestPage = ProtectionRequestPage;