Building the daemon, packaging it, measuring it, and cutting a release. For installing and running it, see README.md.
- Linux (Netlink netfilter API required)
- Rust 1.85+ (project uses edition 2024)
- Docker, for building
.debpackages CAP_NET_ADMIN(or root) to run the daemon or the end-to-end tests
cargo build # debug
cargo build --release # optimized release
cargo test # unit and integration tests
cargo fmt --check # formatting
cargo clippy # lintsBinary output is target/debug/natstream or target/release/natstream.
Clippy's pedantic lint set lives in Cargo.toml. CI checks every target and
treats warnings as errors with cargo clippy --all-targets -- -D warnings. The
casts that a wire format necessarily makes are annotated where they are, with
the bound that makes each one safe; the few places where the layout of the
source is doing the explaining carry #[rustfmt::skip].
./build.sh produces a .deb for a target distribution, inside a Docker
container based on that distribution. No build tooling is needed on the host
beyond Docker. This is the same script the release workflow runs, so a locally
built package is the packaged one.
./build.sh trixie # Debian 13 -> dist/natstream_*~deb13_*.deb
./build.sh 24.04 # Ubuntu 24.04 LTS -> dist/natstream_*~ubuntu24.04_*.deb
./build.sh 26.04 # Ubuntu 26.04 LTS -> dist/natstream_*~ubuntu26.04_*.deb
./build.sh all # all threeThe Rust toolchain is pinned (RUST_VERSION, default 1.97.1) and installed in the
container, because the distros' own rustc is not usable across all targets —
Ubuntu 24.04 ships 1.75 and this crate needs 1.85+ for edition 2024. Each target
links its own distro's glibc and gets a libc6 dependency derived from the
binary with dpkg-shlibdeps, rather than a guess. In practice the binary
currently only needs GLIBC_2.34, so the three are interchangeable today; the
per-distro build is what keeps that true if it ever stops being.
Override the packaging metadata with MAINTAINER=, DEB_REVISION= and
RUST_VERSION= in the environment. ./build.sh --help lists everything.
cargo bench # everything
cargo bench -- decode # one groupThe benchmarks measure the two steps that cost CPU — decoding a netlink datagram into conntrack events, and encoding those into export messages — separately and together, across every protocol, profile and counter width. Decoding is measured with all events translated, with one in four translated, and with none, because rejecting an event the exporter does not care about is the common case and the cheapest path through the parser.
They deliberately do not measure the recvmmsg receive itself: its cost
includes the kernel and cannot be reproduced without a live conntrack.
Criterion baselines make the before/after comparison a two-step affair. The gate is on the lower bound of the confidence interval, so a regression has to be one the statistics are confident about before it fails:
./benches/compare.sh --save before # on the unchanged tree
# ...make the change...
./benches/compare.sh --against before # exits non-zero if anything got slower--threshold PCT sets what counts as a regression (default 5%), --filter EXPR
narrows it to some of the benchmarks, and --list shows the saved baselines.
Results are only as steady as the machine underneath them; for numbers worth
arguing over, pin the CPU governor to performance.
The microbenchmarks say how fast the code is, not whether the daemon keeps up with a kernel that is actually producing events. That needs the real thing:
cargo build --release
./tests/e2e/run-netns.sh --throughputIt creates tens of thousands of NAT sessions in a namespace, tears them all down, and reports what was offered, what the kernel had to drop because the exporter did not drain its socket in time, and what the collector received. The netlink drop count is the one that answers the question — records received is a floor, since a Python collector on loopback is the slower end.
Treat it as a sizing exercise rather than a regression gate: the load is an
instantaneous burst, harsher than real traffic, and the result moves with the
machine. Build for release first, or the number says more about -O0 than
about the exporter.
A release is a tag. .github/workflows/release.yml builds one .deb per
target with ./build.sh, then publishes them to a GitHub release:
# Cargo.toml's version must already say 0.2.0 on the commit being tagged.
git tag -a 0.2.0 -m 'NatStream 0.2.0'
git push origin 0.2.00.2.0 and v0.2.0 both trigger it, since the repository has tags of each
kind. The workflow refuses to build if the tag and Cargo.toml disagree, so a
mismatch costs a failed job rather than a wrong package. A version with a
suffix (0.2.0-rc1) is published as a pre-release.
Before publishing, the Ubuntu 24.04 package is installed and purged on the
runner, which is the same distribution: a maintainer script that fails, a
binary that will not run, or an install that enables the service fails the
build. The release carries the packages and a SHA256SUMS over them.
Running the workflow by hand from the Actions tab builds all three packages and leaves them as workflow artifacts without publishing anything, which is how to exercise the packaging path without spending a tag. Giving it a tag instead builds that tag and attaches the packages to its release, which is how a tag pushed before this workflow existed gets its packages:
gh workflow run release.yml --ref master -f tag=0.1.0An existing release keeps its notes; only the packages are attached.
Set the DEB_MAINTAINER repository variable to a real Name <email>;
without it the packages carry the placeholder maintainer from
packaging/build-deb.sh.
Copyright 2026 FastNetMon LTD.
Licensed under the Apache License, Version 2.0. See LICENSE for the full text and NOTICE for the copyright notice, or https://www.apache.org/licenses/LICENSE-2.0.