Skip to main content

zelos-packet

Rust-first packet capture and decode for the Zelos ecosystem: live capture through a bundled helper process (AF_PACKET on Linux, /dev/bpf on macOS), plus pcap/pcapng offline decode, emitting packet and capture-statistics events into a Zelos trace.

The helper links libpcap statically, so the wheel has no runtime dependency on a system libpcap; the extension module links none at all. Live capture needs a one-time grant (below); offline decode needs none.

pip install zelos-packet

Offline

import zelos_packet, zelos_sdk

with zelos_sdk.TraceWriter("capture.trz"):
    d = zelos_packet.PacketDecoder("dump")
    d.convert_file("dump.pcap")     # or d.decode_stream(raw_bytes)
    # rows land at Packet.dump/packets.<field>

Live

# The helper streams to this agent; rows are read back from there, never from
# a local TraceWriter. Defaults to $ZELOS_AGENT_URL, else localhost:2300.
cap = zelos_packet.PacketCapture("en0", snaplen=128, agent_url="http://localhost:2300")
cap.start()                        # rows land at Packet.en0/packets.<field>
print(cap.stats().kernel_drops, cap.metrics().emit_stall_ms)
cap.stop()
sudo "$(command -v python3)" -m zelos_packet install-helper   # once per machine
python3 -m zelos_packet status                                # would capture work now?

Then pick the group up. Linux stamps a session's group set at login (initgroups), so log out and back in. macOS resolves membership per process, so a fresh terminal is enough. Either way, anything already running (this shell, a running Zelos app) keeps the group set it started with.

Platform What install-helper does What the group grants
Linux Installs the helper to a root-owned path with cap_net_raw=ep (never CAP_NET_ADMIN) and creates/joins zelos-packet The ability to run a capture. The helper opens the socket, drops every capability, verifies the drop from /proc/self/status, and only then reads. It never hands out the socket, so not frame injection.
macOS Installs a ChmodBPF-style boot daemon putting /dev/bpf* in access_bpf Capture and sending arbitrary frames — a bpf device is opened read-write and capture needs the write side, exactly as for Wireshark.

zelos_packet.permission_remediation() returns the same text a failed open raises inside CapturePermissionError.

One capture path: the helper

zelos-packet-helper runs every live capture on both platforms. It opens the handle, drops every capability it holds, and streams decoded rows straight to an agent; PacketCapture supervises that process and nothing else. No packet enters the calling process, so capture privilege never lands on the interpreter and no capture-capable descriptor is handed out.

  • source= is a hard error. Rows are produced behind the helper's own SDK connection, so nothing would reach a source passed here. Use agent_url=.
  • An agent must be reachable for rows to land anywhere. A local TraceWriter sees nothing.
  • Constructing is not starting. PacketCapture(...) validates the interface name, resolves the agent exclusion, and locates a runnable helper, so a typo, an unresolvable agent host, or a missing helper raises there. The permission verdict comes from start(), where the helper opens the handle. (zelos-can starts from its constructor; this does not.)

Catalog layout

Every capture writes into ONE trace source, Packet, and is told apart by its own event-name prefix, so a field is addressed Packet.en0/packets.src_ip:

Packet
└── en0                      # the capture's `name`; defaults to the interface
    ├── packets              # zelos.packet.v1 — one row per frame
    └── stats                # zelos.packet.stats.v1 — one row per interval

Pass name= to override the prefix; it goes through sanitize_name(), which collapses catalog separators (., :, @, /) to _, so a VLAN device eth0.100 lands at Packet.eth0_100/packets.

Two captures may share one source as long as their names differ. Event registration is strict-create: a second capture registering a name already on that source raises rather than merging, which keeps two captures from silently interleaving into one table.

API

Symbol Purpose
PacketDecoder(...) Bring-your-own-bytes: decode_frame, decode_stream, convert_file, push_stats, register_schemas, metrics, flush
PacketCapture(...) Live handle: start, stop, stats, metrics, interface, link_type, is_active, error; also a context manager
list_interfaces() InterfaceInfo per NIC — name, index, up/running/loopback, addresses, MAC
permission_remediation() Copy-pasteable privilege fix for this platform
capture_supported() False outside Linux/macOS; offline decode still works
sanitize_name(name) The event-prefix rule, shared with the packet extension
CapturePermissionError PermissionError subclass, message embeds the remediation
InterfaceNotFoundError ValueError subclass for an unknown interface name

The frame column

frame is the source of truth every decoded column is checked against, so both entry points populate it by default (log_frames=True), clipped to stored_frame_bytes=256 bytes. stored_frame_bytes=None stores every captured byte; log_frames=False drops the column.

Knob Applies to Effect
snaplen live capture only Bytes the kernel copies out. Uncaptured bytes are dissected by nobody; the shortfall shows up as orig_len > cap_len with truncated set.
stored_frame_bytes live + offline Bytes stored in frame. Dissection always reads the full captured bytes, so this changes storage and nothing else.

orig_len / cap_len / truncated describe capture truncation only. Storage truncation is len(frame) < cap_len; the frozen schema has no column for it.

Drop policy

While the sink is consuming, userspace blocks rather than dropping. Every emit is the SDK's normal send path, never try_send, so a full router channel stops the read loop, the kernel ring fills, and the kernel drops where it can be counted.

kernel_drops decode_stall_ms Meaning
0 high Trace store is backpressuring
climbing ~0 Line-rate overload; the read loop is the bottleneck

Metrics.emit_stall_ms is the sink-only slice of decode_stall_ms; the difference is dissection cost.

Where that guarantee stops. Backpressure only exists while something is pulling. If the helper's gRPC publish stream to the agent breaks, the router's publisher subscription goes away while decode keeps running: rows are produced and discarded, and kernel_drops stays flat because the kernel handed them over successfully. So kernel_drops == 0 means "the kernel lost nothing", not "the trace is complete". What does move is Metrics.emit_errors / Metrics.flush_errors, and at stop time CaptureStats.tail_abandoned (non-zero = rows the publisher held never reached the agent) and CaptureStats.kernel_stats_errors (non-zero = kernel_packets / kernel_drops are a stale reading, not totals). Check those before treating a capture as complete. There is no counter for rows dropped into a dead subscription mid-run.

Self-traffic exclusion

When the agent streams over the interface being captured, every emitted row makes more captured bytes. At a large snaplen that loop does not converge.

On by default for PacketCapture, derived from agent_url. With neither exclude_agent_addrs nor exclude_agent_port passed, both come from the URL the capture streams to (agent_url, else $ZELOS_AGENT_URL, else $ZELOS_TRACE_FORWARD_URL, else http://localhost:2300), resolving a host name to every address it has — so the localhost default excludes 127.0.0.1 and ::1 both. PacketDecoder takes no default at all: it is handed bytes that already exist, so there is no loop to close and dropping rows out of a user-supplied file would be silent data loss.

You want Pass
A different endpoint exclude_agent_addrs=[...] and exclude_agent_port=... — no resolution happens
The agent's traffic captured exclude_agent_addrs=[]
Nothing else (nothing)

Either argument alone is an error; the empty list is the one address list a port may be absent from. A host that does not resolve is a construction error, not a fallback to capturing unfiltered.

A live capture compiles the exclusion with pcap_compile and installs it with pcap_setfilter on the helper's handle, so the kernel rejects excluded traffic before a packet is copied out. If pcap_compile cannot express it for the link type, the capture fails to open rather than running unfiltered. Offline decode has no kernel in the path; its userspace AgentFilter matches on ports only, but it is still applied — a pcap being converted loses its agent-endpoint rows unless you pass exclude_agent_addrs=[].

The expression is not recorded in the trace. It is logged at capture start (RUST_LOG=zelos_packet=debug, "compiling the agent filter"), which is the only place to read back what was installed — worth capturing alongside a trace you intend to analyze, because of the three gaps below.

It drops more than the agent port. Closing the IPv4 fragment hole statelessly costs precision, on any port, for the agent addresses only:

Traffic to/from an agent address Kept?
IPv4 first fragment (offset 0) kept, unless on the agent port
IPv4 continuation fragment (offset > 0) dropped, any port
IPv6 with any extension header — Fragment, Hop-by-Hop, Routing, Destination Options, ESP (50) or AH (51) dropped, any port

So fragmented UDP telemetry to an agent address on port 9999 keeps each datagram's first fragment and loses every continuation, and an agent address reached over IPsec transport mode contributes no packets at all. The idiomatic not (host X and port Y) accepts continuation fragments, and those are what the feedback loop is made of; statelessly, rejecting on the address alone is the only correct answer. There is no counter for it either — pcap_stats's ps_recv counts packets that already passed the kernel filter, so excluded traffic appears in no column. To see what was excluded, capture with exclude_agent_addrs=[] on an interface the agent does not stream over.

Tunnels defeat it. Both the kernel program and the userspace backstop read the outer IP header, so agent traffic inside WireGuard, any other VPN, VXLAN or GRE carries its addresses in the inner header and is re-admitted. Capture the tunnel interface (wg0, utun0, …) instead, where the agent's addresses are the outer ones.

Three or more stacked VLAN tags defeat it. libpcap's host / port primitives do not look past a VLAN tag, so the expression is repeated under vlan and vlan and vlan — untagged, single-tagged and QinQ. Under a third tag the address test reads the wrong offset, does not match, and the enclosing not (…) therefore accepts the frame. The arms stop there because each one multiplies the compiled program, and the worst case already sits close to BSD's 512-instruction BPF_MAXINSNS ceiling.

Development

just develop     # uv sync + maturin develop
just test        # cargo test (all three crates) + pytest + check-static
just lint        # cargo clippy -D warnings
just stub_gen    # regenerate py/zelos_packet/_native.pyi

Live-capture tests are opt-in (ZELOS_PACKET_TEST_LIVE=1, optionally ZELOS_PACKET_TEST_IFACE=<name>) and need a zelos-agent binary, since rows are read back out of a real agent. Supervision and the permission verdict need no privilege on any platform: rs/supervisor.rs and test/test_helper_backend.py both drive a fake helper.

The Rust toolchain is pinned by the nix dev shell; there is deliberately no rust-toolchain file. Release wheels come from the api-py-zelos-packet workflow, or locally from just build-linux-wheels; both build the static libpcap with docker/manylinux/build-libpcap.sh (the one place its version, hash and configure flags live) and gate the artifact with docker/manylinux/check-wheel-static.sh. A bare maturin build elsewhere does neither. RELEASE.md has the release order.

Release files for zelos-packet 0.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for zelos-packet 0.0.1
File Interpreter ABI Platform
zelos_packet-0.0.1-cp310-abi3-manylinux_2_28_x86_64.whl CPython 3.10 abi3 Linux glibc 2.28+ x86-64 Details
zelos_packet-0.0.1-cp310-abi3-manylinux_2_28_aarch64.whl CPython 3.10 abi3 Linux glibc 2.28+ ARM64 Details
zelos_packet-0.0.1-cp310-abi3-macosx_11_0_arm64.whl CPython 3.10 abi3 macOS 11.0+ ARM64 Details

Total release size: 13.4 MB

Release files / zelos_packet-0.0.1-cp310-abi3-manylinux_2_28_x86_64.whl

Download URL zelos_packet-0.0.1-cp310-abi3-manylinux_2_28_x86_64.whl
Size 5.2 MB
Tags CPython 3.10 Linux glibc 2.28+ x86-64 abi3
SHA-256 checksum
How to use checksums
3f7fe3d8991eb91a44599549e577bb63a3b3c7df72a505902e2b9b813b3e94a6
BLAKE2b-256 checksum
How to use checksums
f8c91fadbefa22acd6affd9c868c14bda2d0c92857f8390d989fb609290237f5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.13

Release files / zelos_packet-0.0.1-cp310-abi3-manylinux_2_28_aarch64.whl

Download URL zelos_packet-0.0.1-cp310-abi3-manylinux_2_28_aarch64.whl
Size 4.4 MB
Tags CPython 3.10 Linux glibc 2.28+ ARM64 abi3
SHA-256 checksum
How to use checksums
47797e8e45afea2b9995c3cbb6c17430e7ed0898b9567ebcb4b6315126f65a5c
BLAKE2b-256 checksum
How to use checksums
6042712e9c7b678fb25cad9ae21fd9b4ff5019a67a595b469388f42b16350057
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.13

Release files / zelos_packet-0.0.1-cp310-abi3-macosx_11_0_arm64.whl

Download URL zelos_packet-0.0.1-cp310-abi3-macosx_11_0_arm64.whl
Size 3.9 MB
Tags CPython 3.10 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
e9cb78c9a81d9bf4e551b9b9c074e3a31c1807503a492331433157ea8c61db15
BLAKE2b-256 checksum
How to use checksums
bb814623853edb78f0c473c61a2c5812044ad846a7fb58367112f709581232ff
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.13

Release history Release notifications | RSS feed

This release

0.0.1 This release

3 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page