Enforcing Instrument VLAN Firewall Rules from Python
A default-deny host firewall is only as trustworthy as the process that writes its rules. This guide covers compiling a declarative instrument allowlist into nftables from Python and re-asserting it under drift, pinning LXI, VXI-11, and HiSLIP traffic to its control VLAN inside Security Boundaries & Network Isolation. It complements the client-side TCP control layer in securing lab networks for instrument control systems, which assumes the boundary is already correct — this page is the code that makes it correct and keeps it that way.
Baseline Constraints & Scope
The target is a Linux control host that sits on the instrument VLAN and needs its own packet filter, not just a switch ACL upstream. Managed switches and appliance firewalls enforce segmentation, but a scope on 10.20.0.0/24 can still reach a mistyped 8.8.8.8 if the host itself has a default route and no egress filter — and an LXI instrument with a curl-capable embedded Linux is a genuine exfiltration and lateral-movement surface. The scope here is narrow: generate host-side firewall rules from a Python policy object, apply them idempotently, and detect and repair drift. It assumes nftables on Python 3.11+ (the same shape ports to iptables or, via netsh advfirewall, to Windows), a control host that talks to instruments pinned by literal IP through a VISA Resource Manager Setup, and instruments that speak cleartext SCPI on TCP 5025, HiSLIP on 4880, or VXI-11 via the RPC portmapper on 111.
The design contract is fail closed. LXI, VXI-11, and HiSLIP instruments have no business reaching the internet: they pull no updates you have vetted, and a compromised or merely buggy embedded stack that can open an outbound socket is a data-integrity and safety hazard on the same network as a physical actuator. So egress from the instrument VLAN defaults to DROP, and the only flows that survive are the control-host↔instrument tuples the policy names explicitly. Every rule the host runs is derived from one source of truth, so a review of the policy object is a review of the firewall.
Modeling the Policy as an Allowlist Set
The policy is an allowlist set $A$ of permitted 4-tuples. Let a packet be characterized by $p = (\text{src}, \text{dst}, \text{proto}, \text{port})$. The filter admits $p$ if and only if its tuple is a member of $A$, and drops everything else:
Two properties make this safe to compile mechanically. First, membership is order-independent — set inclusion has no notion of “which rule came first” — so the compiler must preserve that by never emitting a broad ACCEPT that a later DROP is supposed to override. In an ordered ruleset (nftables chains, iptables) the first match wins, so the only faithful encoding of default-deny is: emit every allowlist accept first, then a single terminal drop as the chain policy, and never emit a general accept above the drop. Second, the desired state is a pure function of $A$, so drift detection reduces to a set difference between the desired accept-tuples and the tuples currently live in the kernel. If $A_{\text{active}} \neq A$, the host has drifted and must re-assert.
Every accept the kernel enforces is compiled from the allowlist; all other egress — including any instrument’s attempt to reach the internet — hits the terminal default-deny.
Compiling a FirewallPolicy to nftables from Python
The FirewallPolicy dataclass below is the single source of truth. It renders a complete nft -f ruleset into a dedicated table so the apply is atomic and idempotent — nft replaces the whole table in one transaction, so re-running the same policy is a no-op at the kernel level, and there is no half-applied window. Egress from the instrument subnet defaults to drop; only the named tuples and the return traffic of established connections survive. The verify step re-parses the live ruleset as JSON and diffs the active accept-tuples against the desired set, so drift is a structured finding, not a guess.
from __future__ import annotations
import ipaddress
import json
import logging
import subprocess
from dataclasses import dataclass, field
logger = logging.getLogger("instrument_fw")
TABLE = "inet instrument_guard"
class FirewallError(RuntimeError):
"""nft invocation failed or the ruleset could not be asserted."""
@dataclass(frozen=True)
class AllowRule:
"""One permitted control-host -> instrument flow."""
control_host: ipaddress.IPv4Address
instrument: ipaddress.IPv4Address
port: int
proto: str = "tcp" # tcp | udp (udp for VXI-11 portmap)
def tuple(self) -> tuple[str, str, str, int]:
return (str(self.control_host), str(self.instrument), self.proto, self.port)
@dataclass(frozen=True)
class FirewallPolicy:
"""Declarative allowlist that compiles to a default-deny nftables table."""
vlan: ipaddress.IPv4Network # e.g. 10.20.0.0/24 instrument VLAN
rules: tuple[AllowRule, ...] = field(default_factory=tuple)
mgmt_cidr: str | None = None # management plane kept reachable, always
def desired_tuples(self) -> frozenset[tuple[str, str, str, int]]:
return frozenset(r.tuple() for r in self.rules)
def render(self) -> str:
"""Emit a full `nft -f` ruleset. Accepts first, then default-deny."""
for r in self.rules:
if r.instrument not in self.vlan:
raise FirewallError(f"{r.instrument} is outside VLAN {self.vlan}")
lines = ["table " + TABLE + " {",
" chain egress {",
" type filter hook output priority 0; policy drop;",
" ct state established,related accept",
" oifname \"lo\" accept"]
if self.mgmt_cidr: # never lock out the management plane
lines.append(f" ip daddr {self.mgmt_cidr} tcp dport 22 accept")
for r in self.rules: # explicit allowlist accepts
lines.append(
f" ip saddr {r.control_host} ip daddr {r.instrument} "
f"{r.proto} dport {r.port} accept"
)
lines += [" counter drop", " }", "}"]
return "\n".join(lines)
def apply(self) -> None:
"""Atomically replace the table with the rendered ruleset (idempotent)."""
ruleset = f"flush table {TABLE}\n{self.render()}\n"
try:
subprocess.run(["nft", "-f", "-"], input=ruleset, text=True,
check=True, capture_output=True)
except FileNotFoundError as exc:
raise FirewallError("nft binary not found") from exc
except subprocess.CalledProcessError as exc:
raise FirewallError(f"nft apply failed: {exc.stderr.strip()}") from exc
logger.info("applied %d allow rules to %s", len(self.rules), TABLE)
def active_tuples(self) -> frozenset[tuple[str, str, str, int]]:
"""Parse the live ruleset and extract enforced accept-tuples."""
try:
out = subprocess.run(["nft", "-j", "list", "table", TABLE],
check=True, text=True, capture_output=True).stdout
except subprocess.CalledProcessError as exc:
raise FirewallError(f"table {TABLE} not present: {exc.stderr}") from exc
live: set[tuple[str, str, str, int]] = set()
for obj in json.loads(out).get("nftables", []):
expr = obj.get("rule", {}).get("expr", [])
saddr = daddr = proto = None
port: int | None = None
accept = any("accept" in e for e in expr)
for e in expr:
match = e.get("match", {})
left = match.get("left", {}).get("payload", {})
if left.get("field") == "saddr":
saddr = match["right"]
elif left.get("field") == "daddr":
daddr = match["right"]
elif left.get("field") == "dport":
proto = left.get("protocol")
port = match["right"]
if accept and saddr and daddr and port is not None:
live.add((saddr, daddr, proto or "tcp", int(port)))
return frozenset(live)
def drift(self) -> tuple[frozenset, frozenset]:
"""Return (missing, unexpected) tuples: desired minus active, and extra."""
active = self.active_tuples()
desired = self.desired_tuples()
return (desired - active, active - desired)
def reconcile(self) -> bool:
"""Re-assert the policy if the kernel has drifted. True if repaired."""
missing, unexpected = self.drift()
if missing or unexpected:
logger.warning("drift detected: missing=%s unexpected=%s",
sorted(missing), sorted(unexpected))
self.apply()
return True
return False
Validation Against a Test Chain and a Denied Flow
Never validate a fail-closed policy by trusting that apply() returned zero. Prove that a tuple outside the allowlist is actually dropped, and that a tuple inside it passes. The cleanest way is to apply the policy, then assert on the kernel: a denied flow must fail to connect within a short timeout, while an allowed flow to a live instrument returns its *IDN? string. Pair a live check with a drift assertion so the test also confirms reconcile() is a no-op on a clean host.
import socket
import pytest
def _reaches(host: str, port: int, timeout: float = 1.0) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def test_default_deny_blocks_unlisted_egress(policy: FirewallPolicy) -> None:
"""A destination not in the allowlist must not be reachable once applied."""
policy.apply()
# 1.1.1.1:443 is deliberately absent from A -> must be dropped
assert not _reaches("1.1.1.1", 443), "default-deny egress leaked"
# counter on the drop rule should be advancing
out = subprocess.run(["nft", "list", "table", TABLE],
text=True, capture_output=True).stdout
assert "counter" in out and "drop" in out
def test_allowlisted_instrument_is_reachable(policy: FirewallPolicy) -> None:
"""A permitted control-host -> instrument tuple survives the filter."""
policy.apply()
rule = policy.rules[0]
assert _reaches(str(rule.instrument), rule.port), "allowlist blocked a valid flow"
assert policy.reconcile() is False, "clean host should not report drift"
Between test cases, delete the table with nft delete table inet instrument_guard in a fixture teardown so a failed assertion never leaves a host firewalled. On a live rig, watch the drop counter climb under watch -n1 'nft list table inet instrument_guard' while an instrument’s embedded stack tries and fails to reach a vendor telemetry endpoint — a steadily incrementing counter with no operational impact is the signal the boundary is doing its job. Validated commands crossing that boundary should already conform to Command Set Standardization, and the audit record of every apply and every drift repair belongs alongside the run’s Metadata Injection Workflows so an access-control trail exists for 21 CFR Part 11 review.
Failure Modes & Edge Cases
Four failure modes are specific to compiling and enforcing an instrument firewall from code, and each has a concrete diagnosis:
- A broad ACCEPT shadowing the DROP. In an ordered chain the first match wins, so a well-meaning
oifname "eth0" acceptemitted above the allowlist silently defeats default-deny — every packet matches the general rule and the drop is dead code. The compiler here only ever emits narrow accepts and one terminalcounter drop, but if you extendrender(), keep specific accepts first and never add a general one. Diagnose by readingnft -a list table inet instrument_guardand confirming the drop rule’s counter is nonzero under real traffic; a flatlined counter means something upstream is accepting everything. - Rules lost after reboot. nftables is not persistent by default. A host that reboots mid-campaign comes back with an empty table and no egress filter at all — the worst possible failure for a fail-closed design, because it fails open. Render the policy to
/etc/nftables.confand enablenftables.service, and treatreconcile()as a boot-time and periodic task (a systemd timer) so the running kernel is continuously re-driven to $A$. Detect the gap withsystemctl is-enabled nftablesin your pre-run checklist. - DHCP moving an instrument out from under a pinned rule. The allowlist pins a literal IP; if an instrument’s lease renews to a new address, the pinned accept now points at nothing and the device is unreachable while the drop silently blackholes it. Give every instrument a static DHCP reservation or a static IP, and have
reconcile()alert when a configured instrument stops answering on its pinned tuple. A suddenVI_ERROR_TMOright after a lease window is the tell; classify it through Error Code Categorization so a moved-IP fault is not retried as a transient timeout. - An over-broad deny locking out the management plane. A policy that drops all egress can strand the very SSH session you are editing it from, or cut the host off from NTP and the log collector — a remote host becomes unrecoverable without console access. The
mgmt_cidraccept exists precisely to keep the management path open; always render it, and stage risky changes behind anftrollback timer (apply, sleep, auto-revert unless confirmed) so a mistake self-heals instead of bricking the host.
Related guides
- Security Boundaries & Network Isolation — the zone model and allow-list port reference this host filter enforces locally.
- Securing lab networks for instrument control systems — the finite-state TCP control client that runs on top of this boundary.
- VISA Resource Manager Setup — pinning instruments by literal IP so the allowlist tuples stay valid.
- Metadata Injection Workflows — attaching the firewall audit trail to the run record for access-control review.
- Error Code Categorization — distinguishing a moved-IP block from a transient timeout before recovery.
References
- nftables wiki — configuring and scripting rulesets
- NIST SP 800-82 Rev. 3 — Guide to Operational Technology Security
← Back to Security Boundaries & Network Isolation