Host Firewall
- POST /network/host-firewall/apply
Host firewall: apply posture (observe/enforce)
Flips this node’s firewall posture. Body: {mode (required: “observe”|”enforce”), ttl_seconds (optional int, enforce-only dead-man deadline, default 60, ignored for observe)}. enforce gates through PreEnforceCheck then arms a dead-man BEFORE applying → 200 {token,mode,ttl_seconds,node}; observe applies straight → 200 {mode}. node is this node’s hostname so the UI routes the follow-up /confirm and /revert (the token is node-local) to the same node. 409 = enforce refused: either the node is already enforcing (confirm/revert the pending change first) or the gate blocked (reach-uncovered or dry-run parse fail); nothing applied in either case. The mode-file-write 500 is the ONE error body that carries a token (kernel change + dead-man succeeded; only the on-disk record failed — the caller must still /confirm).
- Status Codes:
200 OK – enforce {token,mode,ttl_seconds,node} or observe {mode}
400 Bad Request – malformed body, or mode not observe|enforce
401 Unauthorized – missing/invalid auth token
409 Conflict – enforce refused: already enforcing, or gate blocked (reach-uncovered / dry-run parse fail); nothing applied
500 Internal Server Error – apply/arm/snapshot failed, or mode-file write failed (that body also carries token)
- GET /network/host-firewall/audit
Host firewall: audit trail
One WINDOW of this node’s append-only firewall audit trail (rule upserts, apply flips), oldest-first within the window. limit defaults to 100; offset counts FORWARD FROM THE OLDEST entry (0 = oldest page), matching every other paged list; a client that wants the newest entries asks for the LAST page. The response carries total so a client can tell whether an older page exists. A bad ?limit= or ?offset= is ignored. Read-only. Set X-Destination-Node to read a remote node’s trail; omit for local.
- Query Parameters:
limit (integer) – max entries to return (default 100)
- Status Codes:
200 OK – {entries:[{ts,action,actor,detail,rendered_hash}]}
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – read failed: <cause>
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- Response JSON Object:
entries[].action (string)
entries[].actor (string)
entries[].detail (string)
entries[].rendered_hash (string)
entries[].ts (string)
limit (integer)
offset (integer)
total (integer)
- POST /network/host-firewall/confirm
Host firewall: confirm (disarm the dead-man)
Disarms the commit-confirm dead-man for the token returned by /apply enforce — the proof the enforce apply did not strand the operator. Body: {token (required)}. Empty/missing token → 400; a Confirm failure (timer could not be stopped, e.g. unknown/expired token) → 500.
- Status Codes:
200 OK – {ok:true}
400 Bad Request – malformed body, or empty/missing token
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – confirm failed (timer could not be stopped)
- GET /network/host-firewall/drift
Host firewall: drift (live vs desired)
Whether the live owned table matches what the current role+mode would render, computed fresh on every read (never persisted). reason is one of “owned table absent from kernel (stomped or not yet applied)” / “live enforce state does not match desired mode” / “live rule count differs from desired render”, and is omitted when not drifted. desired_hash is a sha256 of the desired nft text for client-side change detection only.
- Status Codes:
200 OK – {drifted, role, mode, live_present, live_enforcing, live_rules, desired_enforcing, desired_rules, desired_hash, reason?}
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – detect failed: <cause>
- GET /network/host-firewall/manifest
Host firewall: effective manifest
The EFFECTIVE allow-list for this node’s role — the compiled own-list merged with deploy-time drop-ins and operator rules. Each allow carries its service, proto, port (or port_lo/port_hi/all_ports), plane, reach, whether it is system-reserved (non-editable), and source (compiled|dropin|operator) so a reader can tell why the port is open. Read-only; the only failure is auth. Set X-Destination-Node to read a remote node’s manifest; omit for the local node.
- Status Codes:
200 OK – OK
401 Unauthorized – missing/invalid auth token
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- Response JSON Object:
allows[].all_ports (boolean)
allows[].direction (string) – Direction is always “ingress” for a compiled or drop-in allow: this list describes what may ARRIVE. It is stated rather than left implicit because this endpoint is the node’s Ports-Protocols-Services evidence, and DISA’s own host PPSM rule speaks in direction — Ubuntu 22.04 STIG UBTU-22-251030’s fix text is ufw allow <direction> in|out. An artifact that omits direction forces a reviewer to assume it, and the assumption is only right until the day an egress rule exists. Egress lives exclusively in operator rules (GET /rules carries its own direction), so a constant here is the honest answer rather than a placeholder: nothing the platform compiles is outbound.
allows[].peers[] (string)
allows[].plane (string)
allows[].port (integer)
allows[].port_hi (integer)
allows[].port_lo (integer)
allows[].proto (string)
allows[].reach (string)
allows[].reserved (boolean)
allows[].service (string)
allows[].source (string) – Source answers “why is this port open”: “compiled” (core’s own curated list), “dropin” (a deploy-time component declared it), or “operator” (someone added a rule). The merged manifest is all this endpoint serves, so without this an operator cannot tell a port core insists on from one a plugin asked for or a colleague added last week — and only one of those three is safe to remove. Empty only if a node predates the field.
role (string)
- GET /network/host-firewall/nodes
Host firewall: node registry
The mgmt-side list of known nodes ({os_ip, hostname, fqdn}) from the BMC inventory — the targets the UI node-forwards firewall detail requests to via X-Destination-Node. Management-server aggregate: NOT node-forwarded. 503 when called on a node with no registry (not a management server).
- Status Codes:
200 OK – {nodes:[{os_ip,hostname,fqdn}]}
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – registry read failed: <cause>
503 Service Unavailable – node registry unavailable (not a management server)
- Response JSON Object:
nodes[].fqdn (string)
nodes[].hostname (string)
nodes[].os_ip (string)
- GET /network/host-firewall/planes
Host firewall: plane model
Managed plane→bridge bindings (mgmt→cloudbr0, storage→cloudbr1), the operator-chosen fabric underlay interface (”” when no fabric is configured), and the compiled reserved floor for this node’s role (mgmt [22,443,8097,9096,9191,9443] / agent [22,8097,9096]). Read-only; the only failure is auth.
- Status Codes:
200 OK – {role, planes:[{plane,bridge}], fabric_iface, reserved_floor, available_interfaces}
401 Unauthorized – missing/invalid auth token
- POST /network/host-firewall/planes
Host firewall: upsert plane→bridge override
Upserts one operator-authored plane→bridge override ({plane: mgmt|storage, bridge}). Validated (managed plane set + non-empty, whitespace-free bridge) before persist: an unknown plane or bad bridge is a 400 with the engine’s message and nothing is written. On success the override is persisted atomically and the resolver applies it (override beats the compiled default) on the next render; audited best-effort. Set X-Destination-Node to target a remote node; omit for local.
- Request JSON Object:
bridge (string)
plane (string)
- Status Codes:
200 OK – {ok:true}
400 Bad Request – malformed body, or invalid plane/bridge (validation message)
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – persist failed
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- GET /network/host-firewall/ports
Host firewall: port-keyed surface
The operator-authored allow/deny rules persisted for this node (rules.json), each with the same {direction, plane, proto, port} key that PUT/DELETE /rules address — plus peers, which for a DENY is part of that key and must be echoed back on DELETE to address the right row — plus best-effort actor/ts provenance. This is the operator-editable subset — distinct from GET /manifest, which is the full compiled+reserved effective allow-list. Empty (rules:[]) when none exist. Read-only; fail-safe (a missing/corrupt store returns [] , never 500). Set X-Destination-Node to read a remote node; omit for local. One row per port (or port RANGE — ranges are NOT expanded, so tcp/6800-7568 stays a single row and a client searching for a port must match WITHIN a range). Each row carries the services the manifest declares there, the source (compiled|dropin|operator), the LIVE listening addresses and owning processes read from the node’s socket table, and the plane names where the port is allowed vs denied. The list is a UNION: a port allowed with nothing listening appears (a compiled allow for a stopped service), and so does a port listening that the manifest never names (the fabric ports — ospfd/bgpd/BFD are admitted via the fabric interface, not as plane allows). processes may be absent while listening is true: /proc attribution is best-effort and a wrong name is worse than none. Set X-Destination-Node to read a remote node; omit for local.
- Status Codes:
200 OK – {role, ports[]}
401 Unauthorized – missing/invalid auth token
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
X-Destination-Node – target node OS IP/FQDN; omit for local node
- Response JSON Object:
ports[].allowed_planes[] (string)
ports[].denied_planes[] (string)
ports[].listen_addrs[] (string)
ports[].listening (boolean) – Listening is explicit rather than inferred from an empty ListenAddrs, because “allowed but nothing is running” is a real and common state that must not read as missing data.
ports[].peers[] (string)
ports[].port (integer) – Port is set for a single-port row; PortLo/PortHi for a range. RANGES ARE NOT EXPANDED — tcp/6800-7568 stays one row, because expanding it would put 769 rows on screen for one line of config. A consumer searching for a port must therefore match WITHIN a range, not equal it.
ports[].port_hi (integer)
ports[].port_lo (integer)
ports[].processes[] (string)
ports[].proto (string)
ports[].services[] (string)
ports[].sources[] (string)
ports[].verdict (string) – Verdict classifies coverage: “covered” | “restricted” | “isolated” | “uncovered” | “off-plane”, or “” when nothing is listening. Server-computed with the SAME comparison Detect uses, so a console never infers it from an empty allowed_planes — that inference looks obvious and is wrong for a listener bound to an address no managed plane carries (bfdd on 172.30.40.1, observed live): it has no allowed plane AND no denied plane, enforce never rejects it, and calling it uncovered puts a non-problem in an operator’s decision queue. Only “uncovered” is a problem — enforce would reject the listener on every bridge it is reachable on. “isolated” is the control working. “restricted” is an accept narrowed to named peers: not a fault, but not “accepted everywhere it is reachable” either, which is why it cannot be folded into “covered”.
role (string)
- GET /network/host-firewall/reserved
Host firewall: reserved port floor + never-deny interfaces
The compiled, non-editable floors. reserved_floor is the control floor enforce must guarantee, sorted+deduped: mgmt [22,443,8097,9096,9191,9443], agent [22,8097,9096]. exempt_ifaces is the never-deny interface floor for a future host-wide default-deny — entries ending in ‘*’ are nft iifname prefix patterns; DECLARED but NOT yet enforced, since today’s enforce denies only the managed plane bridges. Read-only; the only failure is auth.
- Status Codes:
200 OK – {role, reserved_floor, exempt_ifaces}
401 Unauthorized – missing/invalid auth token
- Response JSON Object:
exempt_ifaces[] (string)
reserved_floor[] (integer)
role (string)
- POST /network/host-firewall/revert
Host firewall: revert to snapshot
Replays this node’s safe-mode snapshot for the token (rolls the owned table back to the pre-enforce state and disarms the dead-man). Body: {token (required)}. Empty/missing token → 400; a Revert failure (unknown/expired token or replay error) → 500. On success the on-disk posture is forced back to observe (the kernel table was reverted) and the revert is audited best-effort. Set X-Destination-Node to target a remote node; omit for local.
- Request JSON Object:
token (string)
- Status Codes:
200 OK – {ok:true}
400 Bad Request – malformed body, or empty/missing token
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – revert failed (unknown/expired token or replay error)
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- GET /network/host-firewall/rules
Host firewall: port-keyed surface
The operator-authored allow/deny rules persisted for this node (rules.json), each with the same {direction, plane, proto, port} key that PUT/DELETE /rules address — plus peers, which for a DENY is part of that key and must be echoed back on DELETE to address the right row — plus best-effort actor/ts provenance. This is the operator-editable subset — distinct from GET /manifest, which is the full compiled+reserved effective allow-list. Empty (rules:[]) when none exist. Read-only; fail-safe (a missing/corrupt store returns [] , never 500). Set X-Destination-Node to read a remote node; omit for local. One row per port (or port RANGE — ranges are NOT expanded, so tcp/6800-7568 stays a single row and a client searching for a port must match WITHIN a range). Each row carries the services the manifest declares there, the source (compiled|dropin|operator), the LIVE listening addresses and owning processes read from the node’s socket table, and the plane names where the port is allowed vs denied. The list is a UNION: a port allowed with nothing listening appears (a compiled allow for a stopped service), and so does a port listening that the manifest never names (the fabric ports — ospfd/bgpd/BFD are admitted via the fabric interface, not as plane allows). processes may be absent while listening is true: /proc attribution is best-effort and a wrong name is worse than none. Set X-Destination-Node to read a remote node; omit for local.
- Status Codes:
200 OK – {role, ports[]}
401 Unauthorized – missing/invalid auth token
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
X-Destination-Node – target node OS IP/FQDN; omit for local node
- Response JSON Object:
ports[].allowed_planes[] (string)
ports[].denied_planes[] (string)
ports[].listen_addrs[] (string)
ports[].listening (boolean) – Listening is explicit rather than inferred from an empty ListenAddrs, because “allowed but nothing is running” is a real and common state that must not read as missing data.
ports[].peers[] (string)
ports[].port (integer) – Port is set for a single-port row; PortLo/PortHi for a range. RANGES ARE NOT EXPANDED — tcp/6800-7568 stays one row, because expanding it would put 769 rows on screen for one line of config. A consumer searching for a port must therefore match WITHIN a range, not equal it.
ports[].port_hi (integer)
ports[].port_lo (integer)
ports[].processes[] (string)
ports[].proto (string)
ports[].services[] (string)
ports[].sources[] (string)
ports[].verdict (string) – Verdict classifies coverage: “covered” | “restricted” | “isolated” | “uncovered” | “off-plane”, or “” when nothing is listening. Server-computed with the SAME comparison Detect uses, so a console never infers it from an empty allowed_planes — that inference looks obvious and is wrong for a listener bound to an address no managed plane carries (bfdd on 172.30.40.1, observed live): it has no allowed plane AND no denied plane, enforce never rejects it, and calling it uncovered puts a non-problem in an operator’s decision queue. Only “uncovered” is a problem — enforce would reject the listener on every bridge it is reachable on. “isolated” is the control working. “restricted” is an accept narrowed to named peers: not a fault, but not “accepted everywhere it is reachable” either, which is why it cannot be folded into “covered”.
role (string)
- PUT /network/host-firewall/rules
Host firewall: upsert operator rule
Upserts one operator-authored allow/deny rule ({service, proto: tcp|udp, one of port OR port_lo/port_hi, plane: mgmt|storage|public, decision: allow|deny}). Validated against this node’s role + reserved floor before persist: a bad shape or a reserved-port target is a 400 with the engine’s message. On success the rule is persisted (latest decision per plane+proto+port wins — service is a LABEL, not part of the identity, so re-deciding a port REPLACES the earlier rule and a rename does NOT create a second one) and audited best-effort. Set X-Destination-Node to target a remote node; omit for local.
- Request JSON Object:
decision (string)
direction (string) – Direction is “ingress” | “egress”; omitted MEANS ingress, so every pre-egress caller keeps its exact meaning. ITS ABSENCE HERE MADE EGRESS UNREACHABLE THROUGH THE API. The storage type, the normaliser, the emitter and the shape validation all handled direction — only this request struct did not carry it, so the field was dropped at the boundary and every rule an operator submitted as egress was persisted as ingress. The validation at hostfw.ruleShapeError sat waiting for a value that could not arrive. On DELETE it is equally load-bearing, because direction is part of ruleKey. A delete with no direction resolves to the INGRESS row: a console editing a rule to egress would PUT the new rule (stored as ingress, direction discarded) and then DELETE what it believed was the stale row — resolving to the identical key and removing the row it had just written. Observed on a live node, twice in the audit log, one second apart, both calls returning 200.
peers[] (string)
plane (string)
port (integer)
port_hi (integer)
port_lo (integer)
proto (string)
service (string)
- Status Codes:
200 OK – {ok:true}
400 Bad Request – malformed body, or invalid/reserved rule (validation message)
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – persist failed
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- POST /network/host-firewall/rules/preview
Host firewall: preview an operator rule
Dry-runs a candidate rule against the stored rules and returns what saving it WOULD do, persisting nothing. Body is the same shape as PUT /rules. Returns {valid, error?, replaces?, shadows[], narrows[], self_shadowed_by?}. replaces is the destructive one: rule identity is direction|plane|proto|portLo, plus the peer set FOR A DENY, so a single-port rule collides with a RANGE starting on the same port and two rules differing only in direction collide — saving OVERWRITES that row. A peer-scoped deny does NOT collide with an allow on the same port (that is how allow-plus-exception is expressed), but an ALLOW’s peers never key it, so narrowing an allow’s peer list UPDATES that allow rather than adding a second, wider row. shadows are existing rules this candidate would render inert; narrows are allows it would shrink (a deny punches a hole rather than deleting, so before/after carry the spans); self_shadowed_by means the CANDIDATE itself lands inert behind an existing deny. A validation refusal is a 200 with valid=false and the engine’s message, NOT a 4xx — the preview ran and produced a verdict. Set X-Destination-Node to preview against a remote node; omit for local.
- Request JSON Object:
decision (string)
direction (string) – Direction is “ingress” | “egress”; omitted MEANS ingress, so every pre-egress caller keeps its exact meaning. ITS ABSENCE HERE MADE EGRESS UNREACHABLE THROUGH THE API. The storage type, the normaliser, the emitter and the shape validation all handled direction — only this request struct did not carry it, so the field was dropped at the boundary and every rule an operator submitted as egress was persisted as ingress. The validation at hostfw.ruleShapeError sat waiting for a value that could not arrive. On DELETE it is equally load-bearing, because direction is part of ruleKey. A delete with no direction resolves to the INGRESS row: a console editing a rule to egress would PUT the new rule (stored as ingress, direction discarded) and then DELETE what it believed was the stale row — resolving to the identical key and removing the row it had just written. Observed on a live node, twice in the audit log, one second apart, both calls returning 200.
peers[] (string)
plane (string)
port (integer)
port_hi (integer)
port_lo (integer)
proto (string)
service (string)
- Status Codes:
200 OK – what saving it would do
400 Bad Request – malformed body
401 Unauthorized – missing/invalid auth token
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- Response JSON Object:
error (string)
narrows[].after (string)
narrows[].before (string)
narrows[].key (string)
narrows[].service (string)
port_in_use (boolean) – PortInUse: something is LISTENING on this proto/port right now, with the owning processes when /proc could attribute them. It means exactly that — NOT that a second process could not bind it, which is kernel socket semantics this service never participates in.
processes[] (string)
render_error (string)
renderable (boolean) – Renderable: the structural dry-run of the ruleset this candidate WOULD produce, built in memory with no kernel state touched. False means saving it degrades the node to observe at the next reconcile — a refusal the operator should read at the form instead.
replaces.after (string)
replaces.before (string)
replaces.key (string)
replaces.service (string)
self_shadowed_by (string)
shadows[].after (string)
shadows[].before (string)
shadows[].key (string)
shadows[].service (string)
suggested_ports[] (integer)
valid (boolean)
- GET /network/host-firewall/state
Host firewall: live applied state
Live state of the owned karios_fw table, listeners not covered by the allow-list, and whether default-deny may be applied: {role, applied:{present,policy,enforcing,rules}, uncovered:[{proto,addr,port,v6}], readiness:{ready,blockers:[{kind,detail}]}}. policy is “” when the table is absent; enforcing is true when a scoped per-plane deny is present (default-deny is never the base chain policy). readiness.ready=false means the reconciler is applying observe even if the posture file says enforce, and blockers says why (kinds: render-invalid, reach-port, fabric-iface, plane-bridge, uncovered-port). uncovered and blockers are always arrays, never null. Set X-Destination-Node to read a remote node’s own state; omit for the local node.
- Status Codes:
200 OK – live state + uncovered listeners + enforce readiness
401 Unauthorized – missing/invalid auth token
500 Internal Server Error – detect failed: <cause>
- Request Headers:
X-Destination-Node – target node OS IP/FQDN; omit for local node
- Response JSON Object:
applied.enforcing (boolean)
applied.policy (string)
applied.present (boolean)
applied.rules (integer)
posture (any) – Posture answers “what was asked for vs what is running”. Read it before Applied: Applied describes the table, Posture describes the DECISION that produced it.
readiness.blockers[].addr (string)
readiness.blockers[].blocking (boolean) – Blocking distinguishes “enforcing would BREAK this node” from “enforcing would DENY this port”. Only blocking findings stop enforce; advisory ones are reported and denied. A console must render these differently: a blocking finding is something to FIX before the firewall can run at all, an advisory one is a decision — allow the port, or stop the service. Treating them alike is what made a node with 27 denied-but-harmless listeners look as broken as one that would strand its own SSH.
readiness.blockers[].detail (string)
readiness.blockers[].kind (string)
readiness.blockers[].planes[] (string)
readiness.blockers[].port (integer)
readiness.blockers[].process (string)
readiness.blockers[].proto (string) – STRUCTURED FACTS about the listener this finding is about, so a console can offer an action instead of parsing Detail. Absent on findings that describe a node-wide condition rather than a socket (fabric-iface, plane-bridge, manual-posture), which is why every one is omitempty: a missing field means “not about a listener”, not “unknown”. Planes carries PLANE NAMES, not bridges. A rule is written against a plane, and a wildcard listener is commonly denied on more than one, so an “Allow” action is a multi-select whose options come from here. Deriving it in the browser would mean re-implementing the plane→bridge mapping the server owns and an operator can re-point at any time.
readiness.ready (boolean)
role (string)
uncovered[].addr (string)
uncovered[].port (integer)
uncovered[].process (string) – Process is the command owning the socket (“kshield”, “systemd-resolve”), “” when it could not be resolved. A port number alone is not a decision: an operator cannot tell whether to allow tcp/9592 or stop it without knowing what is behind it.
uncovered[].proto (string)
uncovered[].v6 (boolean)
- POST /network/host-firewall/validate
Host firewall: pre-enforce validate (dry-run)
Runs the real PreEnforceCheck (nft -c dry-run + operator-reach coverage). Read-only, mutates nothing. A PASS returns {ok:true,role}; a BLOCKED check returns {ok:false,role,error} but STILL 200 (the check ran and produced a verdict — only auth failure is non-200). error is the nft parse failure or the uncovered reach ports.
- Status Codes:
200 OK – {ok:true,role} on pass, or {ok:false,role,error} when blocked (still 200)
401 Unauthorized – missing/invalid auth token