From 8eea012b2bd3d40c2a0f8b330fb3dd793c13a24b Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 16:39:24 +0200 Subject: [PATCH 1/4] chore(release): 0.4.0 One feature has been on main since v0.3.0 with nothing to release it: the fast-allow path (#36), opt-in and off by default, with the eBPF ABI at v4 - a minor bump. Beside it: the guard that holds the RPM spec to Cargo.toml on every push (#35), a test race fix (#34) and five dependency bumps. The CHANGELOG's [Unreleased] section was written as the branch went; it becomes 0.4.0 as it stands, plus entries for what landed on main next to it. Every place the version is hardcoded moves together - Cargo.toml, the lock, the PKGBUILD, the Colony manifest and its asset name, the RPM spec and its %changelog, pkg/README's build snippet - and both release guards pass locally. The release workflow refuses a tag that does not match [workspace.package], so the tag follows this commit. --- CHANGELOG.md | 36 ++++++++++++++++------ Cargo.lock | 18 +++++------ Cargo.toml | 2 +- packaging/rpm/colony-firewall-control.spec | 8 ++++- pkg/PKGBUILD | 2 +- pkg/README.md | 2 +- pkg/colony.json | 4 +-- 7 files changed, 47 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 930b4b5..7ece114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.4.0] - 2026-09-05 + ### Added - **Fast allow (opt-in, `[ebpf] fast_allow = true`).** A process a lasting @@ -75,20 +77,20 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). already takes the sendmsg hooks that 5.10 refuses, and neither has `group_dead`. - **The startup report says what the fast path's kernel side is capable - of** (`fast_path=ready|sendmsg-unavailable|basic-connect` on the log - line, `none` where no connect hook attached), and the matrix test asserts it per kernel along with `group_dead` - where a run has already shown the answer: 5.10 takes the connect hooks and - refuses the sendmsg ones, 5.15 and 6.12 take both and still have no - `group_dead`, 6.18 and 7.1 have everything. A kernel that changes its - answer fails in CI rather than degrading quietly on a host; one without a - recorded answer is - printed, and the matrix summary carries the line. + of** (`fast_path=ready|sendmsg-unavailable|basic-connect` on the log line, + `none` where no connect hook attached), and the matrix test asserts it per + kernel, along with `group_dead`, wherever a run has already shown the + answer: 5.10 takes the connect hooks and refuses the sendmsg ones, 5.15 and + 6.12 take both and still have no `group_dead`, 6.18 and 7.1 have + everything. A kernel that changes its answer fails in CI rather than + degrading quietly on a host; one without a recorded answer is printed, and + the matrix summary carries the line. ### Changed - Three costs removed from paths every process on the machine takes, none of - them measured on a live kernel - this machine cannot run the daemon - and - each argued from what the code does rather than from a number. The exec and + them measured on a live kernel yet, each argued from what the code does + rather than from a number. The exec and exit programs deleted a fast-allow grant on every `execve` and every exit, unconditionally, on hosts where the feature is off (which is every host by default); the delete is now behind one array read of the mark, which is @@ -117,6 +119,20 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). nftables snippet gains the set and the accept rule; an older snippet leaves fast-allow off with the reason spelled out. +### Fixed + +- The RPM spec still said 0.2.3 in the 0.3.0 tree, and nothing ran to say + so: `scripts/check-versions.sh` now holds the spec, the PKGBUILD and the + Colony manifest to `Cargo.toml` on every push, not only when a packaging + path changes. + +### Internals + +- A test that copied a binary and executed it raced another test's fork + (ETXTBSY on CI); it stages the copy where no fork can hold it open. +- Dependency bumps: rusqlite 0.40.2, libc 0.2.189, flate2 1.1.10, + owo-colors 4.4.0, thiserror 2.0.20; `action-gh-release` 3.0.3. + ## [0.3.0] - 2026-09-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index c8885fa..b7e1dad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,7 +690,7 @@ dependencies = [ [[package]] name = "cfc-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "cfc-client", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "cfc-client" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "cfc-core", @@ -739,7 +739,7 @@ dependencies = [ [[package]] name = "cfc-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "chrono", "ipnet", @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "cfc-daemon" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "aya", @@ -793,11 +793,11 @@ dependencies = [ [[package]] name = "cfc-ebpf-common" -version = "0.3.0" +version = "0.4.0" [[package]] name = "cfc-proto" -version = "0.3.0" +version = "0.4.0" dependencies = [ "prost", "serde", @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "cfc-tray" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "cfc-client", @@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "cfc-ui" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "cfc-client", @@ -6414,7 +6414,7 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xtask" -version = "0.3.0" +version = "0.4.0" [[package]] name = "y4m" diff --git a/Cargo.toml b/Cargo.toml index f1354d9..1c8884b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ exclude = ["crates/cfc-ebpf"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2021" rust-version = "1.88" license = "GPL-3.0-or-later" diff --git a/packaging/rpm/colony-firewall-control.spec b/packaging/rpm/colony-firewall-control.spec index 5d9e2c9..5e73720 100644 --- a/packaging/rpm/colony-firewall-control.spec +++ b/packaging/rpm/colony-firewall-control.spec @@ -11,7 +11,7 @@ %global debug_package %{nil} Name: colony-firewall-control -Version: 0.3.0 +Version: 0.4.0 Release: 1%{?dist} Summary: Application-aware outbound firewall for Linux @@ -239,6 +239,12 @@ fi %{_datadir}/selinux/devel/include/distributed/%{modulename}.if %changelog +* Sat Sep 05 2026 MotherSphere - 0.4.0-1 +- Fast-allow path, opt-in: lastingly allowed processes skip the NFQUEUE round trip +- eBPF ABI v4 +- The daemon arms one nftables set and the SELinux policy grants exactly that +- Kernel matrix brackets RHEL 9 with 5.10 and 5.15; veth latency bench + * Wed Sep 02 2026 MotherSphere - 0.3.0-1 - Hash-bound prompt allows - Tray icon fallback diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index 460acb5..6fb965e 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -4,7 +4,7 @@ # For building from a git checkout during development, see PKGBUILD-git. pkgname=colony-firewall-control -pkgver=0.3.0 +pkgver=0.4.0 pkgrel=1 pkgdesc="Application-aware outbound firewall for Linux, written in Rust" arch=('x86_64') diff --git a/pkg/README.md b/pkg/README.md index 7f45431..1f96bc9 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -148,7 +148,7 @@ To reproduce the tarball locally, from the repo root: cargo build --workspace --release --locked cargo xtask build-ebpf # cfc-ebpf.o; postInstall fails outright without it -V=0.3.0 +V=0.4.0 NAME="colony-firewall-control-${V}-linux-x86_64" STAGE="$(mktemp -d)/${NAME}" mkdir -p "${STAGE}" diff --git a/pkg/colony.json b/pkg/colony.json index 9b57bea..ca50c4f 100644 --- a/pkg/colony.json +++ b/pkg/colony.json @@ -1,7 +1,7 @@ { "id": "colony-firewall-control", "name": "Colony Firewall Control", - "version": "0.3.0", + "version": "0.4.0", "description": "Application-aware outbound firewall for Linux. Per-app prompts, persistent rules, live connection feed.", "license": "GPL-3.0-or-later", "homepage": "https://github.com/Project-Colony/Colony-Firewall-Control", @@ -11,7 +11,7 @@ "tags": ["firewall", "security", "network", "outbound", "opensnitch"], "platforms": { "linux-x86_64": { - "asset": "colony-firewall-control-0.3.0-linux-x86_64.tar.zst", + "asset": "colony-firewall-control-0.4.0-linux-x86_64.tar.zst", "binaries": ["colony-firewalld", "colony-firewall", "colony-firewall-tray", "cfc"], "installPath": "/usr/bin", "postInstall": [ From 10096296d47e4dd459f5bf6033a09614d32807ff Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 16:40:50 +0200 Subject: [PATCH 2/4] chore(release): describe #34 from its commit, not from memory The Internals bullet said the ETXTBSY fix staged the copy out of reach of other forks. e9d7e19 did no such thing: it serialises copy and spawn behind one lock, so the fork-to-exec window of one test no longer overlaps the other's write. A changelog line written from memory is the class of mistake TODO.md section 6 already records; this one was caught before the tag. --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ece114..e92efe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,8 +128,10 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Internals -- A test that copied a binary and executed it raced another test's fork - (ETXTBSY on CI); it stages the copy where no fork can hold it open. +- Two integration tests that each copied a binary and spawned it raced each + other's fork (ETXTBSY on CI, the window between a fork and its exec, where + the child still holds the other test's write descriptor); copy and spawn + are now serialised behind one lock instead of retried past the race. - Dependency bumps: rusqlite 0.40.2, libc 0.2.189, flate2 1.1.10, owo-colors 4.4.0, thiserror 2.0.20; `action-gh-release` 3.0.3. From d8c7ce98d0a353435217c3d1872e22a407ad08e4 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 16:59:57 +0200 Subject: [PATCH 3/4] chore(release): the eBPF crate's own lock, and a guard so it is not skipped a third time crates/cfc-ebpf is its own workspace with its own committed Cargo.lock, and nothing builds it with --locked, so a bump that regenerated the root lock left this one saying 0.3.0 in a 0.4.0 tree. The 0.3.0 release had done the same (its lock said 0.2.3 until the fast-allow merge moved it in passing). The first build of the tag would have rewritten the file and left the checkout dirty; a --locked build of the crate would have refused. The one line moves, and scripts/check-versions.sh now holds the `cfc-ebpf-common` entry of that lock to [workspace.package] with the others - checked on every push and again at tag time by release.yml. Proven to fail on a lock left behind before being trusted to pass. --- crates/cfc-ebpf/Cargo.lock | 2 +- scripts/check-versions.sh | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/cfc-ebpf/Cargo.lock b/crates/cfc-ebpf/Cargo.lock index 04cbb0e..510312d 100644 --- a/crates/cfc-ebpf/Cargo.lock +++ b/crates/cfc-ebpf/Cargo.lock @@ -106,7 +106,7 @@ dependencies = [ [[package]] name = "cfc-ebpf-common" -version = "0.3.0" +version = "0.4.0" [[package]] name = "itoa" diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh index 23fffb0..e779fa8 100755 --- a/scripts/check-versions.sh +++ b/scripts/check-versions.sh @@ -6,6 +6,14 @@ # - pkg/colony.json every "version" field and every version embedded # in an "asset" filename # - packaging/rpm/colony-firewall-control.spec Version: +# - crates/cfc-ebpf/Cargo.lock the `cfc-ebpf-common` entry. The kernel +# crate is its own workspace with its own +# committed lock, and nothing builds it with +# --locked, so a bump that regenerated only +# the root lock shipped 0.3.0 with this one +# still saying 0.2.3, and 0.4.0 nearly did +# the same. `cargo update -p cfc-ebpf-common` +# from crates/cfc-ebpf moves it. # # The spec is here because the job that compared it lived in rhel.yml, which # only runs when packaging paths change - so a version bump that touched none @@ -70,6 +78,10 @@ fi spec_ver="$(sed -n 's/^Version:[[:space:]]*//p' "${ROOT}/packaging/rpm/colony-firewall-control.spec" | head -n1)" check "packaging/rpm/colony-firewall-control.spec Version" "${spec_ver}" +# crates/cfc-ebpf/Cargo.lock -> the version recorded for cfc-ebpf-common +ebpf_lock_ver="$(awk '/^name = "cfc-ebpf-common"$/ { getline; sub(/^version = "/, ""); sub(/"$/, ""); print; exit }' "${ROOT}/crates/cfc-ebpf/Cargo.lock")" +check "crates/cfc-ebpf/Cargo.lock cfc-ebpf-common" "${ebpf_lock_ver}" + if [[ "${fail}" -ne 0 ]]; then echo "version mismatch (canonical: Cargo.toml [workspace.package] = ${cargo_ver}):" >&2 for m in "${mismatches[@]}"; do From 79b3b70558750e2feecf077e88fde1f29d8a635c Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 16:59:57 +0200 Subject: [PATCH 4/4] fix(ebpf): flush the fast-allow set at every daemon start, not only when the layer loads The changelog and the architecture doc promised the nftables set is flushed unconditionally at every start, so that a daemon that crashed while armed cannot leave its predecessor's mark accepted. The flush lived at the top of load_and_attach, which a daemon started with `[ebpf] enabled = false`, or built without the layer, never reaches - and the set outlives daemons. In that one configuration the promise was false and the stale element was a standing bypass token for any process that had once read the value off its own socket. `ebpf::flush_stale_fast_allow` does the flush for those two starts: main calls it when the layer is switched off in the config, the no-feature branch of `start` calls it itself. Not under --dry-run, which touches nothing by definition and runs unprivileged in the smoke test, where a warning from this module is a failure. A table that is not loaded yet is already a silent success in `flush`, so a boot before the nft unit stays quiet. The normal path is unchanged: the loader still flushes first. Found by the release critique, reading the changelog against the code. --- CHANGELOG.md | 7 ++++--- crates/cfc-daemon/src/ebpf.rs | 18 ++++++++++++++++++ crates/cfc-daemon/src/main.rs | 8 ++++++++ docs/ARCHITECTURE.md | 4 ++-- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92efe3..a76b871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,9 +111,10 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and written by nobody for two releases, is gone. - The daemon now runs `nft` to put its fast-allow value into one set and take it out again - the first time it touches nftables; the SELinux policy - grants exactly that. The set is flushed unconditionally at every start, so - a daemon that crashed while armed and came back with the path off does not - leave its predecessor's mark accepted; and once armed the daemon re-checks + grants exactly that. The set is flushed at every start of the daemon - with + the layer on, switched off in the config, or absent from the build - so a + daemon that crashed while armed and came back in any of those states does + not leave its predecessor's mark accepted; and once armed the daemon re-checks every minute that the element is still there, so an `nft -f` that reloads the ruleset is noticed and re-armed rather than reported as live. The nftables snippet gains the set and the accept rule; an older snippet leaves diff --git a/crates/cfc-daemon/src/ebpf.rs b/crates/cfc-daemon/src/ebpf.rs index e2d7a00..8d768fe 100644 --- a/crates/cfc-daemon/src/ebpf.rs +++ b/crates/cfc-daemon/src/ebpf.rs @@ -744,6 +744,21 @@ pub struct Runtime { _attached: Option, } +/// Flushes a previous daemon's fast-allow mark out of the nftables set, for +/// the starts where [`start`] never reaches the loader's own flush: the layer +/// switched off in the config, or a build without it. The set outlives +/// daemons and the accept rule reads it whether or not anything still marks, +/// so a daemon that crashed while armed and came back with the layer off +/// would otherwise leave a standing bypass token behind it. A table that is +/// not loaded yet is not an error here - the nft unit is ordered after the +/// daemon - and `--dry-run` must not call this at all: it touches nothing, and +/// `main` is the one that knows it is running. +pub fn flush_stale_fast_allow() { + if let Err(e) = nft_set::disarm_for_start() { + tracing::warn!("could not flush a previous fast-allow mark from nftables: {e:#}"); + } +} + /// Brings the eBPF layer up, as far as it will come up on this host. /// /// Never returns an error: every failure mode is a note in the [`Report`]. @@ -797,6 +812,9 @@ pub fn start( // `dns` and `table` are the loader's inputs; without it they are // simply never wired to anything. let _ = (dns, table); + // And the loader's flush of a predecessor's mark is never reached in + // this build, so it happens here. + flush_stale_fast_allow(); Runtime { report: Report::inert_because( cfg.enabled, diff --git a/crates/cfc-daemon/src/main.rs b/crates/cfc-daemon/src/main.rs index 96310d5..d0082af 100644 --- a/crates/cfc-daemon/src/main.rs +++ b/crates/cfc-daemon/src/main.rs @@ -323,6 +323,14 @@ async fn run() -> anyhow::Result<()> { // sock_diag + /proc alone, which is exactly what the daemon does when the // layer is unavailable anyway. // + // The loader flushes a predecessor's fast-allow mark at the top of every + // load. With the layer switched off in the config that flush is never + // reached, and the nftables set outlives daemons - so it is done here for + // exactly that case. Not under --dry-run, which touches nothing. + if !args.dry_run && !cfg.ebpf.enabled.wants_load() { + ebpf::flush_stale_fast_allow(); + } + // Held for the daemon's lifetime: dropping it detaches the programs. let _ebpf = ebpf::start( // `--dry-run` means "tell me what you would do without touching the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 79d3838..45ee4db 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -362,8 +362,8 @@ with a single exception: under `[ebpf] fast_allow`, the sockets of a process the daemon has already ruled allowed process-wide are marked in the connect hook, and the snippet's `meta mark @fast_allow accept` rule takes them ahead of the queue. That set is the one thing the daemon ever writes to nftables - -one element added when the path is armed, flushed unconditionally at every -start and at shutdown - and it ships empty, so a default install carries no +one element added when the path is armed, flushed at every daemon start (whether +or not the layer loads) and at shutdown - and it ships empty, so a default install carries no bypass value. **Where revocation reaches.** A grant is re-decided at every hook that opens a