feat(docker): bpf packet filter on kernel links via cls_bpf match-drop

The GNS3 bpf filter (one expression per line, any line matching drops the
packet) now runs on the kernel datapath: uBridge's tc bpf_drop compiles
each line with libpcap (same compiler as the relay filter) into classic
BPF, attaches it to the veth host end's clsact egress as a cls_bpf
classifier with a gact TC_ACT_SHOT action, and the kernel migrates the
cBPF to eBPF internally -- no CAP_BPF needed.

- reconcile is flush + one add per line at priorities 10, 11, ... (OR
  semantics, same as the relay's per-line filters); tc reset (link
  delete / clear) is the full restore in uBridge and clears them
- capability-gated: the compute probes "tc capabilities" once per uBridge
  process (cached, invalidated on stop); without cbpf=1 the filter apply
  fails with an actionable upgrade error, and old uBridge builds without
  the tc module are never touched when no bpf filter is present
- a line that compiled for the controller's tcpdump but not for the
  compute's uBridge libpcap is skipped with a warning, mirroring the
  relay path
- KERNEL_UNSUPPORTED_FILTERS shrinks to frequency_drop (its eBPF stateful
  classifier is still pending, spec part B)

e2e (22/22): bpf "icmp" = 100% loss; size-discriminating expression
("greater 150") passes 98-byte and drops 300-byte pings (real byte-level
match); two-line OR; coexistence with netem (delay 50 -> small pings
100.2 ms while big dropped); clear removes clsact; suspend/resume keeps
the drops; link delete + recreate leaves no residual filters.
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:08 +08:00
1 parent bd1c205651
commit 2b45f29b2c
7 files changed
+303 -50

No files matched your search

+91 -5
View File
@@ -177,6 +177,11 @@ class DockerVM(BaseNode):
# container death. Kernel links enslave it into a Linux bridge,
# relay links attach it to the uBridge relay via AF_PACKET.
self._kernel_veths = {}
# uBridge tc-module capability report, probed lazily once per uBridge
# process (None = not probed yet, {} = no tc support, else the parsed
# "tc capabilities" reply). Invalidated on stop: the next start runs a
# fresh uBridge process which may differ (upgrade/downgrade).
self._ubridge_tc_caps = None
if adapters is None:
self.adapters = 1
@@ -1430,6 +1435,9 @@ class DockerVM(BaseNode):
self._console_websocket = None
self._cleanup_console_resources()
await self._clean_servers()
# The next start spawns a fresh uBridge process — its capabilities
# must be re-probed (see _ubridge_tc_capabilities).
self._ubridge_tc_caps = None
# veth host ends live in the root namespace and survive container
# death — remove them while the uBridge control channel is still up.
await self._remove_kernel_veths()
@@ -1935,8 +1943,10 @@ class DockerVM(BaseNode):
# taps) — the anchor is the interface, not a relay bridge.
await self._ubridge_apply_markers(host_ifc, nio)
# Impairment filters become one tc netem qdisc on the veth host
# end (restored here on node restart, like the capture above).
# end (restored here on node restart, like the capture above);
# bpf expressions become cls_bpf match-drop classifiers.
await self._ubridge_apply_netem(host_ifc, nio.filters)
await self._ubridge_apply_bpf_drops(host_ifc, nio.filters)
return
# Relay NIO. On a veth-backed adapter (the unified Docker interface)
@@ -1998,9 +2008,11 @@ class DockerVM(BaseNode):
if self.ubridge:
if isinstance(nio, NIOBridge):
# The controller keeps relay-only filters off kernel links and
# create_nio rejects them on the POST path; this PUT path
# mutates the NIO in place, so guard here too.
# The controller keeps frequency_drop off kernel links (no
# kernel equivalent until the eBPF classifier lands) and
# create_nio rejects it on the POST path; this PUT path
# mutates the NIO in place, so guard here too. bpf is fine —
# it becomes cls_bpf match-drop classifiers (cbpf-probed).
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio.filters or {})
if unsupported:
raise DockerError(
@@ -2014,8 +2026,10 @@ class DockerVM(BaseNode):
# the relay-datapath equivalent of the branch below.
await self._ubridge_apply_markers(host_ifc, nio)
# netem set is an atomic replace, so a full re-apply
# IS the incremental reconcile for impairment filters.
# IS the incremental reconcile for impairment filters;
# bpf drops reconcile as flush + re-add.
await self._ubridge_apply_netem(host_ifc, nio.filters)
await self._ubridge_apply_bpf_drops(host_ifc, nio.filters)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
return
@@ -2172,6 +2186,78 @@ class DockerVM(BaseNode):
if "No such file" not in str(e):
raise
async def _ubridge_tc_capabilities(self):
"""
uBridge's tc-module feature report (``tc capabilities``), probed once
per uBridge process and cached — the reply shape is
``netem=<kw,...>;ebpf=0|1;cbpf=0|1``. uBridge builds without the tc
module answer "Unknown module" and old builds without the command
answer "Unknown command": both mean no kernel filter support, cached
as an empty dict.
"""
if self._ubridge_tc_caps is None:
caps = {}
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
try:
reply = await self._ubridge_hypervisor.send("tc capabilities")
except UbridgeError:
reply = []
for entry in (reply[0] if reply else "").split(";"):
key, _, value = entry.partition("=")
if key:
caps[key.strip()] = value.strip()
self._ubridge_tc_caps = caps
return self._ubridge_tc_caps
async def _ubridge_apply_bpf_drops(self, host_ifc, filters):
"""
Translate the NIO's ``bpf`` filter (one expression per line, any line
matching drops the packet) into cls_bpf classifiers on the veth host
end's clsact egress: ``tc bpf_drop`` compiles each line with libpcap
(same compiler as the relay's bpf filter) and drops matches with a
gact TC_ACT_SHOT. Reconcile is always flush + re-add — same-prio
resend replaces the filter node in uBridge, and the flush drops
lines the user removed.
:param host_ifc: veth host-end interface name
:param filters: NIO filters dictionary
"""
bpf = filters.get("bpf")
lines = [line.strip() for line in (bpf[0].split("\n") if bpf else []) if line.strip()]
if not lines and self._ubridge_tc_caps is None:
# Nothing to add and nothing was ever installed (no tc module
# probed yet) — skip touching an old uBridge entirely.
return
caps = await self._ubridge_tc_capabilities()
if not lines:
if caps:
await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"')
return
if caps.get("cbpf") != "1":
raise DockerError(
"Packet filter 'bpf' on a kernel-datapath link needs a uBridge with cBPF support "
"(tc capabilities reports no cbpf); upgrade uBridge on this compute or keep the "
"link on the relay datapath"
)
await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"')
for offset, line in enumerate(lines):
try:
await self._ubridge_send(
'tc bpf_drop add "{ifc}" {prio} "{expr}"'.format(ifc=host_ifc, prio=10 + offset, expr=line)
)
except UbridgeError as e:
if "Cannot compile filter" not in str(e):
raise
# Mirror the relay path: a line that no longer compiles on
# this uBridge's libpcap is skipped with a warning instead
# of breaking the link (the controller already validated
# the syntax with tcpdump at create/update time).
message = f"Warning: ignoring BPF packet filter '{self.name}' due to syntax error: {line}"
log.warning(message)
self.project.emit("log.warning", {"message": message})
async def _remove_kernel_markers(self, host_ifc):
"""
Tear down every kernel marker anchored to *host_ifc*: the veth survives
+7 -6
View File
@@ -66,7 +66,6 @@ class TAPNIO(BaseModel):
class BridgeNIOType(str, Enum):
bridge = "nio_bridge"
@@ -74,17 +73,19 @@ class BridgeNIO(BaseModel):
"""
Kernel-datapath bridge Network Input/Output properties. The NIO tells the
node to enslave its veth host end into the named kernel bridge instead of
wiring a uBridge UDP relay. Impairment filters with a tc netem equivalent
(delay, packet_loss, corrupt) run on the veth host end; the relay-only
kinds (frequency_drop, bpf) are rejected. Markers and packet capture are
served by uBridge's AF_PACKET modules on the veth host end.
wiring a uBridge UDP relay. Impairment filters run on the veth host end:
delay/packet_loss/corrupt as one tc netem qdisc, bpf as cls_bpf match-drop
classifiers (needs a uBridge reporting cbpf). frequency_drop still has no
kernel equivalent and is rejected. Markers and packet capture are served
by uBridge's AF_PACKET modules on the veth host end.
"""
type: BridgeNIOType
bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5")
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
filters: Optional[dict] = Field(
None, description="Packet filters served by tc netem on the veth host end (delay/packet_loss/corrupt)"
None,
description="Packet filters served on the veth host end (delay/packet_loss/corrupt via netem, bpf via cls_bpf)",
)
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
+6 -4
View File
@@ -15,10 +15,12 @@ class FilterValidationError(Exception):
pass
# Packet filters with no kernel-datapath equivalent (no tc netem mapping):
# they only run in the uBridge userspace relay, so a link carrying one is
# wired on the relay, and adding them to a kernel-datapath link is rejected.
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop", "bpf"})
# Packet filters with no kernel-datapath equivalent yet: frequency_drop needs
# the eBPF stateful classifier (frozen in
# docs/design/ubridge-kernel-impairment-spec.md, part B — not delivered),
# so it still only runs in the uBridge userspace relay. bpf is served by the
# cBPF match-drop classifier (tc bpf_drop) when uBridge reports cbpf=1.
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop"})
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]: