diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b0930f..aa58b00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,19 @@ jobs: libncurses-dev \ dbus-x11 - - name: Configure + - name: Install eBPF toolchain (best-effort; build is skip-safe) + run: | + # The birth-attribution program needs clang(bpf)+libbpf+bpftool at + # BUILD time. clang+libbpf-dev are reliable; bpftool ships standalone + # on some images and via linux-tools on others. All best-effort: if + # any piece is missing, QIFTOP_HAVE_BPF resolves OFF and the agent + # builds conntrack-only (the program is still validated on the images + # where the toolchain lands). + sudo apt-get install -y --no-install-recommends clang libbpf-dev \ + && { sudo apt-get install -y --no-install-recommends bpftool \ + || sudo apt-get install -y --no-install-recommends "linux-tools-$(uname -r)" \ + || echo "::notice::bpftool unavailable — agent builds conntrack-only"; } \ + || echo "::notice::eBPF toolchain unavailable — agent builds conntrack-only" run: | # Debug builds enable ASan + UBSan on test targets only (production # binaries stay clean). Slower than vanilla Debug but catches @@ -140,7 +152,8 @@ jobs: ncurses-devel \ dbus-daemon dbus-tools \ libasan libubsan \ - shadow-utils" + shadow-utils \ + clang libbpf-devel bpftool" ok=0 for attempt in 1 2 3; do if dnf_install $pkgs; then ok=1; break; fi diff --git a/AGENTS.md b/AGENTS.md index 16e60fe..1b648df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,11 @@ src/ │ │ ├── CgroupClassifier.{h,cpp}, CgroupParse.h # pid → container runtime/id/chain │ │ ├── NetnsScanner.{h,cpp} # per-netns socket dump (setns) │ │ ├── ProcDetails.{h,cpp} # on-demand /proc/ reads -│ │ └── ProcSnapshot.h # /proc//stat starttime parser +│ │ ├── ProcSnapshot.h # /proc//stat starttime parser +│ │ └── bpf/ # CO-RE eBPF socket-birth program +│ │ ├── birth.bpf.c # fexit/fentry probes → ring buffer +│ │ ├── birth_events.h # shared wire struct (BPF ↔ userspace) +│ │ └── vmlinux_min.h # minimal CO-RE type subset (no BTF at build) │ ├── dbus/ # libqiftop DBus source proxies for consumers │ │ ├── DBusNetworkMonitor.{h,cpp} │ │ └── DBusConnectionMonitor.{h,cpp} @@ -884,7 +888,8 @@ are an integration-tier follow-up, not part of the pure `bench/` set. | `test_services` | `ConnectionsService` / `InterfacesService` driven in-process via the fake monitors (no real D-Bus bus): snapshot cap (4099→4096 top-by-bytes), dropped-flow skip, process/container/chain attribution, server-side direction; interface-stat caching. v0.4 §5 deep pass: a `FakeDeepWorker` proves weak (NoLocalSocket) flows are enqueued, `AttributionChanged` patches `m_last` on refine (with the real post-direction key), stale/unknown-key and no-new-info updates are dropped, and a runtime `off` hint suppresses enqueue; plus the `attribution-async-refinement` token gate. | | `test_deep_queue` | `DeepAttributionQueue` discipline: dedup by key (latest generation wins, attempts carried forward), priority-by-bytes dequeue (top talkers first), hard-cap drops the quietest, `clear`, and zero-capacity keeps/admits nothing. Pure. | | `test_birth_cache` | `BirthCache` (eBPF birth+conntrack hybrid): insert/find by the direction-AGNOSTIC 5-tuple `BirthKey` (matches a conntrack flow whose direction/ifIndex differ from the birth); proto-sensitive miss; TTL expiry; `prune` reaps expired; clear-on-overflow at the cap; re-inserting the same key updates in place without overflow; `remove`. Pure data structure — no kernel, births injected. | -| `test_bpf_birth_resolver` | `BpfBirthResolver`: inert until `setLoaded(true)` (empty caps, resolvePid=0 → clean conntrack-only chain on unsupported kernels); resolves a cached birth + enriches comm/uid from birth (no `/proc`); rejects a recycled PID via the injected starttime probe (and evicts the stale entry); rejects when the live starttime is unreadable; cache miss → 0; TTL expiry via injected clock; UDP vs TCP tuple isolation; and the production chain shape via `CompositeResolver` (birth FIRST wins for the flow it saw, sock_diag-like stub attributes the rest, caps union includes `birth-attribution`). No kernel/BPF. | +| `test_bpf_birth_resolver` | `BpfBirthResolver`: inert until `setLoaded(true)` (empty caps, resolvePid=0 → clean conntrack-only chain on unsupported kernels); resolves a cached birth + enriches comm/uid from birth (no `/proc`); rejects a recycled PID via the injected starttime probe (and evicts the stale entry); **serves a GONE pid** (`live==0` → the short-lived process exited but its captured attribution is historically correct — the primary hybrid case); cache miss → 0; TTL expiry via injected clock; UDP vs TCP tuple isolation; and the production chain shape via `CompositeResolver` (birth FIRST wins for the flow it saw, sock_diag-like stub attributes the rest, caps union includes `birth-attribution`). No kernel/BPF. | +| `test_birth_decode` | `decodeBirth` (`backend/linux/BirthDecode.h`): the pure eBPF-wire-event → `BirthKey`/`BirthRecord` decoder. v4/v6 network-order bytes → `QHostAddress`; host-order ports; IANA proto → `L4Proto`; direction byte → `Direction`; `start_boottime_ns` → `/proc` field-22 clock ticks tracking `sysconf(_SC_CLK_TCK)` (100/250/1000, and 0 → 0); `ts_ns` → mono ms; comm NUL-bounded + never over-reads a full-16 unterminated comm. Hand-built events, no kernel/libbpf. | | `test_deep_worker` | `ResolverDeepWorker` (v0.4 §5): retries weakly-attributed flows on a fast coalescing timer and emits exactly one `refined()` once a fake resolver finally attributes the flow (reason → Resolved), drains afterward; ages out an unresolvable flow at `deepMaxAttempts` without emitting; an inactive worker ignores enqueue; with `deepDemandNetnsScan` on it nudges the resolver's `requestDeepScan()` for flows that resist, and never does so when the flag is off. Drives a real event loop. | | `test_cgroup_parse` | `classifyPathChain` + `classifyPath` synthetic-path coverage of every supported regex (docker systemd + cgroupfs + legacy, containerd, cri-o, podman rootful/rootless, lxd/lxc, nspawn, k3d nested chain, naked k8s cgroupfs/systemd drivers, /user.slice exclusion). Tier-1 regex-shape protection. | | `test_cgroup_real_fixtures` | Data-driven: 18 real-world `/proc//cgroup` fixtures harvested from upstream docs (Docker, containerd CRI, K8s burstable/guaranteed, CRI-O, Podman rootless/rootful, LXD systemd, LXC, systemd-nspawn machinectl/template, host init/session/system-service scopes, /user.slice manager + app under user@.service). Adding a runtime = drop a fixture + add one table row. | @@ -1459,6 +1464,90 @@ can be dropped with `vagrant destroy default`. --- +## 8b. eBPF socket-birth attribution (the birth+conntrack hybrid) + +The conntrack+sock_diag resolver path attributes a flow by walking +`/proc` and sock_diag on a periodic (~1 s) snapshot, so it structurally +**misses short-lived processes**: the owner has already exited by the +time the dump runs (Phase-0 measurement: ~100% miss on churny workloads). +The hybrid closes that gap by capturing the owning pid + direction + +5-tuple at flow **birth** — synchronously in the owner's context, before +it can die — with a CO-RE eBPF program, and looking conntrack flows up +against those births FIRST. conntrack still provides the **bytes**; birth +has none. This is augmenting, not a capture replacement. + +* **Kernel program** — `src/backend/linux/bpf/birth.bpf.c`. Four BPF + trampoline probes, mirroring the validated bench + `bench/integration/bpf-eval/birth.bt`: + `fexit/tcp_v{4,6}_connect` (outbound TCP — at RETURN the ephemeral + source port is assigned; the `inet_sock_set_state` SYN_SENT tracepoint + fires too early and reports `local_port=0`), `fentry/udp_sendmsg` + (outbound connected UDP, deduped per-sock via an LRU map), + `fexit/inet_csk_accept` (inbound TCP — the return value is the child + sock). Births flow to userspace over a `BPF_MAP_TYPE_RINGBUF`. +* **Why fexit/fentry, not k(ret)probes** — the bench used kprobes; + production uses BPF trampolines because they read args AND the return + value with no `pt_regs`/arch register macros and no entry→return + sock-stash map, and are cheaper. Semantically identical (fexit on + connect == the validated kretprobe). The tradeoff: fentry/fexit need + `CONFIG_FUNCTION_TRACER` (+ BTF); a kernel with that disabled falls + back to conntrack-only (skip-safe). Distro kernels 6.6–7.1 all ship it. +* **CO-RE, no vmlinux.h** — every struct-field access is relocated + against the target kernel's BTF at load, so one object loads unchanged + across kernels. We deliberately do NOT generate a multi-megabyte + `vmlinux.h`: `bpf/vmlinux_min.h` hand-declares only the handful of + fields read (`sock_common`, `task_struct.start_time`, the map-type + enums), each under `preserve_access_index` so the declared offsets are + irrelevant. Net effect: **the BUILD host needs no kernel BTF** (CO-RE + needs it only at RUNTIME on the target). Anonymous-union kernel members + (`skc_daddr`, `skc_dport`, …) are declared flat; CO-RE resolves them by + name. If many more types are ever needed, switch to a build-time + `bpftool btf dump … format c` with a dev-box-sanitised checked-in + fallback — for this field set, minimal wins. +* **Build** — `src/backend/linux/CMakeLists.txt`, gated on + `QIFTOP_HAVE_BPF` (the skip-safe `clang`+`bpftool`+`libbpf>=1.0` + detection in the top-level `CMakeLists.txt`). `clang -target bpf` → + `birth.bpf.o` → `bpftool gen skeleton` → `birth.skel.h`, which EMBEDS + the object bytes, so the program rides inside the agent binary and + **nothing extra ships in the package**. `backend_linux` depends on the + skeleton target, so CI compiles + validates the program on every push + wherever the toolchain is present. +* **Wire struct** — `bpf/birth_events.h` is the byte-for-byte contract + between the BPF program and the userspace ring-buffer reader: addresses + are raw network-order bytes (v4 in `[0,4)`), ports host order, + `start_boottime_ns` is `task->start_boottime` — the field + `/proc//stat` field 22 is derived from (NOT `start_time`) — which the + reader converts to clock ticks for the PID-reuse guard (§8a rule 2). +* **Userspace reader** — `backend/linux/BpfBirthReader.{h,cpp}` (gated on + `QIFTOP_HAVE_BPF`) loads the skeleton (`birth.skel.h`), attaches the probes, + and drains the ring buffer on a dedicated `std::thread`, feeding each event + through the pure `backend/linux/BirthDecode.h` (`decodeBirth`: wire event → + `BirthKey`/`BirthRecord`, network-order bytes → `QHostAddress`, + `start_boottime_ns` → field-22 clock ticks via `sysconf(_SC_CLK_TCK)`) to a + `Sink` callback. Skip-safe: `start()` returns false (and stays inert) on any + kernel without BTF / trampolines / `CAP_BPF`, so the chain runs + conntrack-only. Attach is **per-probe tolerant**: each program is attached + individually (`bpf_program__attach`) and the ones that take are kept, so a + kernel where one traced function is renamed/inlined/non-attachable still + yields births from the rest (`start()` succeeds if ≥1 probe attaches; logs + `attached N/M probes`). Load stays whole-object (CO-RE relocation/verification + is per-object — if that fails nothing is attachable). `decodeBirth` is + unit-tested with hand-built events (`test_birth_decode`, no kernel). The remaining **factory wiring** — construct + `BpfBirthResolver` FIRST in `createProcessResolver`, point a `BpfBirthReader` + at its `onBirth`, `setLoaded(true)` on a successful attach, advertise the + `birth-attribution` token — lands in a follow-up PR. `BirthCache` / + `BpfBirthResolver` (the transport-neutral cores, `src/backend/`) already exist. +* **PID-reuse guard serves GONE pids.** `BpfBirthResolver`'s guard rejects a + cached birth only when a DIFFERENT live process now holds the pid + (`live != 0 && live != captured`). A gone pid (`live == 0`) is STILL served: + short-lived processes — the whole point of birth — have usually exited by the + time the conntrack flow resolves, so `/proc/` is gone, yet the captured + `(pid, comm)` is the historically-correct owner and nothing live can be + confused with it. (A reusing process emits its own birth keyed by its tuple, + overwriting — it never silently steals the attribution.) + +--- + ## 8a. Lifetime & races in process / container attribution Process and container attribution (`ProcessResolver` and its diff --git a/src/backend/BpfBirthResolver.h b/src/backend/BpfBirthResolver.h index 8fd00a4..8fe6821 100644 --- a/src/backend/BpfBirthResolver.h +++ b/src/backend/BpfBirthResolver.h @@ -69,6 +69,16 @@ class BpfBirthResolver : public ProcessResolver { if (m_enrichByPid.size() > m_cache.maxEntries()) m_enrichByPid.clear(); // clear-on-overflow, mirrors the cache } + // Periodically reap TTL-expired births. find() only TTL-FILTERS (it + // can't mutate a const cache), so without this, unmatched births would + // pile up to the hard cap and the clear-on-overflow would then drop + // FRESH births too. Pruning every kPruneInterval inserts keeps the live + // set far below the cap on churny hosts, so the overflow path is a + // last-ditch safety net rather than a routine event. + if (++m_sincePrune >= kPruneInterval) { + m_sincePrune = 0; + m_cache.prune(now); + } } // Mark the resolver active (the eBPF program loaded + probes attached). @@ -101,12 +111,20 @@ class BpfBirthResolver : public ProcessResolver { return 0; // PID-reuse guard (AGENTS.md §8a rule 2): the kernel may have recycled - // the pid since birth. Re-check the live starttime; on mismatch the - // cached attribution belongs to a DIFFERENT process — discard it and - // fall through to the next resolver. + // the pid since birth. Re-check the live starttime; only reject when a + // DIFFERENT live process now holds the pid (live != 0 && live != ours). + // + // A GONE pid (live == 0) must STILL be served: short-lived processes — + // the whole reason birth attribution exists — have usually exited by + // the time the conntrack flow is resolved, so /proc/ is gone. The + // captured (pid, comm) is the historically-correct owner of that flow, + // and no live process is masquerading as that pid, so serving it is + // both correct and the entire point. (If the pid were reused, a new + // flow from the reusing process emits its own birth keyed by its tuple, + // overwriting this entry — it never silently steals this attribution.) if (m_startTimeProbe) { const quint64 live = m_startTimeProbe(rec->pid); - if (live == 0 || live != rec->startTime) { + if (live != 0 && live != rec->startTime) { std::scoped_lock lock(m_mu); m_cache.remove(birthKeyOf(flow)); return 0; @@ -147,12 +165,15 @@ class BpfBirthResolver : public ProcessResolver { } private: + static constexpr int kPruneInterval = 4096; // inserts between TTL reaps + mutable std::mutex m_mu; BirthCache m_cache; QHash m_enrichByPid; // pid → comm/uid from birth std::function m_startTimeProbe; std::function m_clockMs; bool m_loaded = false; + int m_sincePrune = 0; }; } // namespace qiftop::backend diff --git a/src/backend/ProcessResolverFactory.cpp b/src/backend/ProcessResolverFactory.cpp index a52223f..031882a 100644 --- a/src/backend/ProcessResolverFactory.cpp +++ b/src/backend/ProcessResolverFactory.cpp @@ -26,6 +26,9 @@ # ifdef QIFTOP_ENABLE_NETNS_SCAN # include "linux/NetnsScanner.h" # endif +# ifdef QIFTOP_HAVE_BPF +# include "linux/BpfBirthSource.h" +# endif #endif namespace qiftop::backend { @@ -46,6 +49,23 @@ createProcessResolver(const ProcessResolverConfig &cfg) #ifdef QIFTOP_HAS_LINUX_ATTRIBUTION auto composite = std::make_unique(); +# ifdef QIFTOP_HAVE_BPF + // eBPF socket-birth goes FIRST: it captures the owning pid at connect()/ + // accept() time, recovering short-lived processes that sock_diag misses + // because they've already exited by the periodic dump. Skip-safe — if the + // program can't load/attach (no BTF / trampolines / CAP_BPF) the source's + // initialize() returns false and we just don't add it (conntrack-only). + if (cfg.processAttribution) { + auto r = std::make_unique(); + if (r->initialize()) { + qCInfo(lcVerbose) << "ProcessResolverFactory: BpfBirthSource added (eBPF birth, first)"; + composite->add(std::move(r)); + } else { + qCInfo(lcVerbose) << "ProcessResolverFactory: eBPF birth unavailable " + "(no BTF/trampoline/CAP_BPF) — conntrack-only attribution"; + } + } +# endif # ifdef QIFTOP_ENABLE_PROCESS_ATTRIBUTION if (cfg.processAttribution) { auto r = std::make_unique(cfg.tuning); diff --git a/src/backend/linux/BirthDecode.h b/src/backend/linux/BirthDecode.h new file mode 100644 index 0000000..b1e685b --- /dev/null +++ b/src/backend/linux/BirthDecode.h @@ -0,0 +1,79 @@ +#pragma once + +// Pure decoder: one eBPF wire birth event → (BirthKey, BirthRecord). No kernel, +// no /proc, no libbpf — just struct reshaping + byte-order/units conversion, so +// it's unit-tested without loading any BPF. The ring-buffer reader +// (BpfBirthReader) calls this on every drained event; tests call it with +// hand-built events. +// +// NOT gated on QIFTOP_HAVE_BPF: it needs only the shared wire header, not the +// generated skeleton, so the decode logic is always built and tested even on +// images without the eBPF toolchain. + +#include + +#include // __u8/__u16/__u32/__u64 for the wire header +#include // AF_INET6 + +#include +#include +#include + +#include "backend/BirthCache.h" +#include "backend/Connection.h" +#include "backend/linux/bpf/birth_events.h" + +namespace qiftop::backend::linuximpl { + +struct DecodedBirth { + BirthKey key; + BirthRecord rec; +}; + +// Decode one wire event. +// clkTck — sysconf(_SC_CLK_TCK): ticks/sec, to convert start_boottime_ns +// into the /proc field-22 clock-tick units the PID-reuse guard +// compares against (kernel: nsec_to_clock_t = ns * USER_HZ / 1e9). +// nowMonoMs — CLOCK_MONOTONIC ms at insertion (cache TTL aging base). +[[nodiscard]] inline DecodedBirth +decodeBirth(const qiftop_birth_event &e, long clkTck, qint64 nowMonoMs) +{ + DecodedBirth d; + + d.key.proto = fromIanaProto(e.proto); + d.key.localPort = e.local_port; // already host order on the wire + d.key.remotePort = e.remote_port; + + if (e.family == AF_INET6) { + Q_IPV6ADDR a6; + std::memcpy(a6.c, e.local_addr, 16); + d.key.localAddress = QHostAddress(a6); + std::memcpy(a6.c, e.remote_addr, 16); + d.key.remoteAddress = QHostAddress(a6); + } else { + // local_addr[0..4) hold the __be32 in network order; QHostAddress(quint32) + // wants host order, so read the bytes big-endian. + d.key.localAddress = QHostAddress(qFromBigEndian(e.local_addr)); + d.key.remoteAddress = QHostAddress(qFromBigEndian(e.remote_addr)); + } + + d.rec.pid = static_cast(e.pid); + d.rec.uid = e.uid; + + const auto *comm = reinterpret_cast(e.comm); + d.rec.comm = QString::fromUtf8(comm, + static_cast(::strnlen(comm, QIFTOP_BIRTH_COMM_LEN))); + + d.rec.direction = (e.direction == QIFTOP_BIRTH_DIR_OUTBOUND) ? Direction::Outbound + : (e.direction == QIFTOP_BIRTH_DIR_INBOUND) ? Direction::Inbound + : Direction::Unknown; + + d.rec.startTime = (clkTck > 0) + ? (e.start_boottime_ns * static_cast(clkTck) / 1'000'000'000ULL) + : 0; + d.rec.firstSeenMonoMs = static_cast(e.ts_ns / 1'000'000ULL); + d.rec.insertedMonoMs = nowMonoMs; + return d; +} + +} // namespace qiftop::backend::linuximpl diff --git a/src/backend/linux/BpfBirthReader.cpp b/src/backend/linux/BpfBirthReader.cpp new file mode 100644 index 0000000..7bb6a48 --- /dev/null +++ b/src/backend/linux/BpfBirthReader.cpp @@ -0,0 +1,181 @@ +#ifdef QIFTOP_HAVE_BPF + +#include "backend/linux/BpfBirthReader.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "backend/linux/BirthDecode.h" +#include "backend/linux/bpf/birth_events.h" +#include "util/Logging.h" + +#include "birth.skel.h" // generated by bpftool (name qiftop_birth) — embeds the .o + +namespace qiftop::backend::linuximpl { + +namespace { + +// Process-wide CLOCK_MONOTONIC ms, the same base BirthCache TTLs against. One +// shared timer so every reader/record agrees on "now". +qint64 monoMsNow() +{ + static const QElapsedTimer t = [] { + QElapsedTimer e; + e.start(); + return e; + }(); + return t.elapsed(); +} + +// Route libbpf's own logging into our verbose category instead of stderr. +int libbpfPrint(enum libbpf_print_level level, const char *fmt, va_list args) +{ + if (level == LIBBPF_DEBUG) + return 0; + char buf[512]; + std::vsnprintf(buf, sizeof(buf), fmt, args); + qCWarning(lcVerbose).noquote() << "libbpf:" << QString::fromUtf8(buf).trimmed(); + return 0; +} + +} // namespace + +BpfBirthReader::BpfBirthReader(Sink sink) : m_sink(std::move(sink)) +{ + m_clkTck = ::sysconf(_SC_CLK_TCK); + if (m_clkTck <= 0) + m_clkTck = 100; // USER_HZ is 100 on Linux; sane fallback +} + +BpfBirthReader::~BpfBirthReader() +{ + stop(); +} + +bool BpfBirthReader::start() +{ + if (m_running.load()) + return true; + + static std::once_flag once; + std::call_once(once, [] { libbpf_set_print(libbpfPrint); }); + + m_skel = qiftop_birth__open(); + if (!m_skel) { + qCWarning(lcVerbose) << "bpf birth: skeleton open failed " + "(no kernel BTF / libbpf?) — conntrack-only"; + return false; + } + if (qiftop_birth__load(m_skel) != 0) { + qCWarning(lcVerbose) << "bpf birth: program load failed " + "(kernel BTF / verifier?) — conntrack-only"; + qiftop_birth__destroy(m_skel); + m_skel = nullptr; + return false; + } + + // Per-probe TOLERANT attach: attach each program individually and keep the + // ones that take. A kernel where one traced function is renamed/inlined/not + // attachable (or hits the trampoline limit) still yields birth attribution + // from the probes that DID attach, instead of losing all of it. (Load above + // is necessarily whole-object — CO-RE relocation/verification is per-object; + // if THAT fails nothing is attachable anyway.) + int total = 0; + bpf_program *prog = nullptr; + bpf_object__for_each_program(prog, m_skel->obj) { + ++total; + bpf_link *link = bpf_program__attach(prog); + if (!link) { + qCWarning(lcVerbose).noquote() + << "bpf birth: probe" << QString::fromUtf8(bpf_program__name(prog)) + << "attach failed — skipped (other probes continue)"; + continue; + } + m_links.push_back(link); + } + if (m_links.empty()) { + qCWarning(lcVerbose) << "bpf birth: no probes attached " + "(CAP_BPF / CONFIG_FUNCTION_TRACER?) — conntrack-only"; + qiftop_birth__destroy(m_skel); + m_skel = nullptr; + return false; + } + + m_rb = ring_buffer__new(bpf_map__fd(m_skel->maps.births), + &BpfBirthReader::onRingEvent, this, nullptr); + if (!m_rb) { + qCWarning(lcVerbose) << "bpf birth: ring_buffer__new failed — conntrack-only"; + for (bpf_link *l : m_links) + bpf_link__destroy(l); + m_links.clear(); + qiftop_birth__destroy(m_skel); + m_skel = nullptr; + return false; + } + + m_stop.store(false); + m_running.store(true); + m_thread = std::thread([this] { drainLoop(); }); + qCInfo(lcVerbose).noquote() + << QStringLiteral("bpf birth: attached %1/%2 probes; draining ring buffer") + .arg(m_links.size()) + .arg(total); + return true; +} + +void BpfBirthReader::stop() +{ + m_stop.store(true); + if (m_thread.joinable()) + m_thread.join(); + if (m_rb) { + ring_buffer__free(m_rb); + m_rb = nullptr; + } + for (bpf_link *l : m_links) + bpf_link__destroy(l); + m_links.clear(); + if (m_skel) { + qiftop_birth__destroy(m_skel); + m_skel = nullptr; + } + m_running.store(false); +} + +int BpfBirthReader::onRingEvent(void *ctx, void *data, unsigned long size) +{ + auto *self = static_cast(ctx); + if (size < sizeof(qiftop_birth_event)) + return 0; // truncated / version skew — skip + const auto *ev = static_cast(data); + const DecodedBirth d = decodeBirth(*ev, self->m_clkTck, monoMsNow()); + if (self->m_sink) + self->m_sink(d.key, d.rec); + return 0; +} + +void BpfBirthReader::drainLoop() +{ + // Block up to 200 ms per poll so stop() is observed promptly without + // busy-spinning. ring_buffer__poll wakes immediately when events arrive. + while (!m_stop.load()) { + const int n = ring_buffer__poll(m_rb, 200 /*ms*/); + if (n < 0 && n != -EINTR) { + qCWarning(lcVerbose) << "bpf birth: ring poll error" << n << "— stopping reader"; + break; + } + } +} + +} // namespace qiftop::backend::linuximpl + +#endif // QIFTOP_HAVE_BPF diff --git a/src/backend/linux/BpfBirthReader.h b/src/backend/linux/BpfBirthReader.h new file mode 100644 index 0000000..7cb393a --- /dev/null +++ b/src/backend/linux/BpfBirthReader.h @@ -0,0 +1,65 @@ +#pragma once +#ifdef QIFTOP_HAVE_BPF + +#include +#include +#include +#include + +#include "backend/BirthCache.h" + +// Forward-declare the libbpf + generated-skeleton types so this header stays +// free of libbpf / birth.skel.h (those live only in the .cpp). +struct ring_buffer; +struct qiftop_birth; +struct bpf_link; + +namespace qiftop::backend::linuximpl { + +// Loads the CO-RE socket-birth eBPF program, attaches its fexit/fentry probes, +// and drains the ring buffer on a dedicated thread, handing each decoded birth +// to a sink (in production: BpfBirthResolver::onBirth). One reader per agent. +// +// Skip-safe by construction: start() returns false — and the reader stays inert +// with nothing attached — on any kernel that lacks BTF, BPF trampolines +// (CONFIG_FUNCTION_TRACER), or the CAP_BPF/CAP_PERFMON the load needs. The +// caller then simply doesn't setLoaded() the resolver and the chain runs +// conntrack-only. Only compiled when QIFTOP_HAVE_BPF (the skeleton exists). +class BpfBirthReader { +public: + using Sink = std::function; + + explicit BpfBirthReader(Sink sink); + ~BpfBirthReader(); + + BpfBirthReader(const BpfBirthReader &) = delete; + BpfBirthReader &operator=(const BpfBirthReader &) = delete; + + // Open + load + attach the program and spawn the drain thread. Returns + // false (inert) on any failure; safe to call once. Idempotent if already + // running (returns true). + [[nodiscard]] bool start(); + + // Stop the drain thread and tear down the program. Safe to call multiple + // times and from the destructor. + void stop(); + + [[nodiscard]] bool running() const { return m_running.load(); } + +private: + static int onRingEvent(void *ctx, void *data, unsigned long size); + void drainLoop(); + + Sink m_sink; + qiftop_birth *m_skel = nullptr; + ring_buffer *m_rb = nullptr; + std::vector m_links; // per-probe links we attached + own + std::thread m_thread; + std::atomic m_stop{false}; + std::atomic m_running{false}; + long m_clkTck = 0; +}; + +} // namespace qiftop::backend::linuximpl + +#endif // QIFTOP_HAVE_BPF diff --git a/src/backend/linux/BpfBirthSource.h b/src/backend/linux/BpfBirthSource.h new file mode 100644 index 0000000..bed57da --- /dev/null +++ b/src/backend/linux/BpfBirthSource.h @@ -0,0 +1,95 @@ +#pragma once +#ifdef QIFTOP_HAVE_BPF + +#include + +#include "backend/BpfBirthResolver.h" +#include "backend/linux/BpfBirthReader.h" +#include "backend/linux/ProcSnapshot.h" + +// ProcessResolver adapter that owns the eBPF ring-buffer reader AND a +// BpfBirthResolver, wiring births from the former into the latter and gating +// the resolver's "loaded" state (and therefore its capabilities) on a +// successful program attach. This is the integration seam the factory adds +// FIRST in the chain so birth attribution wins for flows it saw. +// +// Lives in backend/linux because it couples the transport-neutral resolver +// core (backend/BpfBirthResolver.h) to the platform reader +// (backend/linux/BpfBirthReader.h). The composite only knows it as a +// ProcessResolver. Skip-safe: initialize() returns false (program couldn't +// load/attach — no BTF, no trampolines, no CAP_BPF) and the factory simply +// doesn't add it, leaving a clean conntrack-only chain. + +namespace qiftop::backend::linuximpl { + +class BpfBirthSource final : public ProcessResolver { +public: + BpfBirthSource() + : m_resolver(std::make_unique( + // PID-reuse guard probe: the live /proc field-22 starttime, in + // the same clock-tick units BirthDecode stored at capture. + [](qint32 pid) -> quint64 { + return procsnap::pidStartTime(pid).value_or(0); + })) + { + // Feed each drained birth into the resolver. The reader thread owns the + // call; BpfBirthResolver::onBirth is internally synchronised. + m_reader = std::make_unique( + [this](const BirthKey &k, const BirthRecord &r) { + m_resolver->onBirth(k, r); + }); + } + + ~BpfBirthSource() override + { + // Stop draining BEFORE the resolver (the sink captures it) is torn down. + if (m_reader) + m_reader->stop(); + } + + BpfBirthSource(const BpfBirthSource &) = delete; + BpfBirthSource &operator=(const BpfBirthSource &) = delete; + + // --- ProcessResolver ---------------------------------------------------- + bool initialize() override + { + if (!m_reader->start()) + return false; // no BTF / trampolines / CAP_BPF + m_resolver->setLoaded(true); // only NOW does it advertise caps / serve + return true; + } + + [[nodiscard]] QStringList capabilities() const override + { + return m_resolver->capabilities(); + } + + [[nodiscard]] qint32 resolvePid(const Connection &flow) override + { + return m_resolver->resolvePid(flow); + } + + [[nodiscard]] std::optional enrichPid(qint32 pid) override + { + return m_resolver->enrichPid(pid); + } + + // Birth carries no container scope — the chain's CgroupClassifier handles + // that on the resolved pid. Delegate (returns nullopt) so the composite + // falls through. + [[nodiscard]] std::optional + resolveContainerForPid(qint32 pid) override + { + return m_resolver->resolveContainerForPid(pid); + } + +private: + // Declaration order matters for teardown: the reader is destroyed first + // (reverse order) so its thread is joined before the resolver it feeds. + std::unique_ptr m_resolver; + std::unique_ptr m_reader; +}; + +} // namespace qiftop::backend::linuximpl + +#endif // QIFTOP_HAVE_BPF diff --git a/src/backend/linux/CMakeLists.txt b/src/backend/linux/CMakeLists.txt index bc4f19a..22ad02b 100644 --- a/src/backend/linux/CMakeLists.txt +++ b/src/backend/linux/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(backend_linux STATIC CgroupClassifier.cpp NetnsScanner.cpp ProcDetails.cpp + BpfBirthReader.cpp ) target_include_directories(backend_linux @@ -24,3 +25,75 @@ target_link_libraries(backend_linux ) target_compile_options(backend_linux PRIVATE ${LIBNL_CFLAGS_OTHER} ${LIBNFCT_CFLAGS_OTHER}) + +# --- eBPF socket-birth attribution program (gated on QIFTOP_HAVE_BPF) -------- +# Compile the CO-RE program (clang -target bpf) and generate a libbpf skeleton +# header that EMBEDS the object bytes — so nothing extra ships in the package; +# the program rides inside the agent binary. QIFTOP_HAVE_BPF is resolved by the +# skip-safe toolchain detection in the top-level CMakeLists (clang+bpftool+ +# libbpf all present); when off, none of this runs and the agent is +# conntrack-only. See AGENTS.md §4 / docs/HACKING.md. +if(QIFTOP_HAVE_BPF) + # bpf_tracing.h keys its (unused-for-fentry) register macros off the target + # arch; pass the right token so the header is happy on non-x86 builders too. + set(_bpf_arch "${CMAKE_SYSTEM_PROCESSOR}") + if(_bpf_arch MATCHES "x86_64|amd64|i.86") + set(_bpf_arch x86) + elseif(_bpf_arch MATCHES "aarch64|arm64") + set(_bpf_arch arm64) + elseif(_bpf_arch MATCHES "^arm") + set(_bpf_arch arm) + elseif(_bpf_arch MATCHES "ppc64|powerpc") + set(_bpf_arch powerpc) + elseif(_bpf_arch MATCHES "s390") + set(_bpf_arch s390) + elseif(_bpf_arch MATCHES "riscv") + set(_bpf_arch riscv) + elseif(_bpf_arch MATCHES "loongarch") + set(_bpf_arch loongarch) + endif() + + set(_bpf_src ${CMAKE_CURRENT_SOURCE_DIR}/bpf/birth.bpf.c) + set(_bpf_obj ${CMAKE_CURRENT_BINARY_DIR}/birth.bpf.o) + set(_bpf_skel ${CMAKE_CURRENT_BINARY_DIR}/birth.skel.h) + + # -g is REQUIRED: bpftool gen skeleton needs the .BTF the debug build emits. + set(_bpf_cflags -g -O2 -target bpf -D__TARGET_ARCH_${_bpf_arch} + -Wall -Werror -I${CMAKE_CURRENT_SOURCE_DIR}/bpf) + foreach(_d ${LIBBPF_INCLUDE_DIRS}) + list(APPEND _bpf_cflags -I${_d}) + endforeach() + + add_custom_command( + OUTPUT ${_bpf_obj} + COMMAND ${QIFTOP_CLANG_BPF} ${_bpf_cflags} -c ${_bpf_src} -o ${_bpf_obj} + DEPENDS ${_bpf_src} + ${CMAKE_CURRENT_SOURCE_DIR}/bpf/vmlinux_min.h + ${CMAKE_CURRENT_SOURCE_DIR}/bpf/birth_events.h + COMMENT "BPF birth.bpf.c -> birth.bpf.o (${_bpf_arch})" + VERBATIM) + + add_custom_command( + OUTPUT ${_bpf_skel} + COMMAND ${QIFTOP_BPFTOOL} gen skeleton ${_bpf_obj} name qiftop_birth > ${_bpf_skel} + DEPENDS ${_bpf_obj} + COMMENT "BPF gen skeleton -> birth.skel.h" + VERBATIM) + + add_custom_target(qiftop_bpf_skel DEPENDS ${_bpf_skel}) + + # The userspace loader/reader (next PR) lives in backend_linux and includes + # "birth.skel.h"; build the skeleton before it and put it on the include + # path now so the wiring is mechanical. Compiling the skeleton in CI also + # validates the eBPF program on every push (where the toolchain is present). + add_dependencies(backend_linux qiftop_bpf_skel) + target_include_directories(backend_linux PRIVATE + ${CMAKE_CURRENT_BINARY_DIR} # birth.skel.h + ${LIBBPF_INCLUDE_DIRS}) + target_link_libraries(backend_linux PRIVATE ${LIBBPF_LIBRARIES}) +endif() + +# backend/linux TUs gate the eBPF reader on QIFTOP_HAVE_BPF (and share the other +# attribution toggles), so hand this target the same compile definitions the +# rest of the tree gets. +qiftop_apply_attribution_defs(backend_linux) diff --git a/src/backend/linux/bpf/birth.bpf.c b/src/backend/linux/bpf/birth.bpf.c new file mode 100644 index 0000000..e1ced9b --- /dev/null +++ b/src/backend/linux/bpf/birth.bpf.c @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * qiftop eBPF socket-BIRTH attribution program (v0.4 birth+conntrack hybrid). + * + * Phase-0 measurement found conntrack capture is fine, but short-lived + * processes are ~100% unattributed: the owning process has EXITED by the time + * the ~1 s snapshot + sock_diag walk runs. This program captures the owning + * pid + direction + 5-tuple at flow BIRTH — the instant connect()/accept()/ + * first connected UDP send fires, synchronously in the owner's context, before + * it can die — and pushes it to userspace over a ring buffer. The userspace + * BpfBirthResolver looks each conntrack flow up here FIRST, recovering the pid + * sock_diag would miss. The hybrid still gets BYTES from conntrack; birth has + * none. + * + * This is the CO-RE production sibling of bench/integration/bpf-eval/birth.bt. + * It mirrors that validated probe set, but uses BPF trampolines (fentry/fexit) + * instead of k(ret)probes: + * - fexit tcp_v{4,6}_connect → outbound TCP (port assigned by return) + * - fentry udp_sendmsg → outbound UDP (connected sends only) + * - fexit inet_csk_accept → inbound TCP (return is the child sock) + * + * Why fexit and not the kretprobe the bench used: fexit reads args AND the + * return value directly from the trampoline (no pt_regs, no arch-specific + * register macros, no entry→return sock-stash map) and is cheaper than a + * kretprobe. Semantically identical: at tcp_v*_connect RETURN the ephemeral + * source port is assigned and we're still in the caller's process context — + * the same reason the bench used the kRETprobe, not the SYN_SENT tracepoint + * (which fires before inet_hash_connect() assigns the port, reporting + * local_port=0). + * + * Portability: CO-RE relocates every struct-field access against the target + * kernel's BTF, so this one object loads unchanged across kernels (validated + * target range 6.6–7.1). It needs a BTF kernel (CONFIG_DEBUG_INFO_BTF=y) with + * BPF trampolines (CONFIG_FUNCTION_TRACER=y + CONFIG_DEBUG_INFO_BTF), and the + * ring buffer (>= 5.8) — all standard on distro kernels. The userspace loader + * is skip-safe: if attach fails (no BTF / no trampoline support) the agent + * runs conntrack-only. + */ +#include "vmlinux_min.h" + +#include +#include +#include + +#include "birth_events.h" + +char LICENSE[] SEC("license") = "GPL"; + +#define AF_INET 2 +#define AF_INET6 10 +#define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +/* The births themselves. 1 MiB ring: at the measured ~3 µs/connect the kernel + * side never blocks, and the userspace reader drains continuously. */ +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 1 << 20); +} births SEC(".maps"); + +/* Dedup connected-UDP births: udp_sendmsg fires per send, but a flow is born + * once. Emit only the first send per sock pointer; LRU bounds it automatically + * so a flood of distinct socks can never grow it unbounded. */ +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 8192); + __type(key, __u64); + __type(value, __u8); +} udp_seen SEC(".maps"); + +static __always_inline __u16 bswap16(__u16 v) +{ + return (__u16)((v >> 8) | (v << 8)); +} + +/* Fill pid/uid/comm/ts/start_time + the static proto+direction. */ +static __always_inline void fill_meta(struct qiftop_birth_event *e, + __u8 proto, __u8 dir) +{ + __u64 pid_tgid = bpf_get_current_pid_tgid(); + + e->ts_ns = bpf_ktime_get_ns(); + e->pid = (__u32)(pid_tgid >> 32); + e->uid = (__u32)bpf_get_current_uid_gid(); + e->proto = proto; + e->direction = dir; + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + e->start_boottime_ns = BPF_CORE_READ(task, start_boottime); +} + +/* Fill family + the 5-tuple from a `struct sock`. local = our bound end. */ +static __always_inline void fill_tuple(struct qiftop_birth_event *e, + struct sock *sk) +{ + __u16 family = BPF_CORE_READ(sk, __sk_common.skc_family); + + e->family = (__u8)family; + e->local_port = BPF_CORE_READ(sk, __sk_common.skc_num); /* host */ + e->remote_port = bswap16(BPF_CORE_READ(sk, __sk_common.skc_dport)); /* be→host */ + + if (family == AF_INET6) { + BPF_CORE_READ_INTO(&e->local_addr, sk, + __sk_common.skc_v6_rcv_saddr.in6_u.u6_addr8); + BPF_CORE_READ_INTO(&e->remote_addr, sk, + __sk_common.skc_v6_daddr.in6_u.u6_addr8); + } else { + __be32 la = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr); + __be32 ra = BPF_CORE_READ(sk, __sk_common.skc_daddr); + __builtin_memcpy(e->local_addr, &la, sizeof(la)); + __builtin_memcpy(e->remote_addr, &ra, sizeof(ra)); + } +} + +static __always_inline int emit(struct sock *sk, __u8 proto, __u8 dir) +{ + if (!sk) + return 0; + struct qiftop_birth_event *e = + bpf_ringbuf_reserve(&births, sizeof(*e), 0); + if (!e) + return 0; /* ring full — drop; conntrack still covers the flow */ + __builtin_memset(e, 0, sizeof(*e)); + fill_meta(e, proto, dir); + fill_tuple(e, sk); + bpf_ringbuf_submit(e, 0); + return 0; +} + +/* --- outbound TCP: fexit, after the ephemeral port is assigned --------- */ + +/* int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len) */ +SEC("fexit/tcp_v4_connect") +int BPF_PROG(qiftop_tcp_v4_connect, struct sock *sk, void *uaddr, int addr_len, + int ret) +{ + if (ret != 0) + return 0; /* connect failed → no flow is born */ + return emit(sk, IPPROTO_TCP, QIFTOP_BIRTH_DIR_OUTBOUND); +} + +/* int tcp_v6_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len) */ +SEC("fexit/tcp_v6_connect") +int BPF_PROG(qiftop_tcp_v6_connect, struct sock *sk, void *uaddr, int addr_len, + int ret) +{ + if (ret != 0) + return 0; + return emit(sk, IPPROTO_TCP, QIFTOP_BIRTH_DIR_OUTBOUND); +} + +/* --- outbound UDP: connected sends only, deduped per sock -------------- */ + +/* int udp_sendmsg(struct sock *sk, struct msghdr *msg, size_t len) */ +SEC("fentry/udp_sendmsg") +int BPF_PROG(qiftop_udp_sendmsg, struct sock *sk) +{ + /* Unconnected sends carry the destination in the msghdr, not the sock, and + * leave skc_dport == 0 — skip them; we only attribute connected UDP. */ + __be16 dport = BPF_CORE_READ(sk, __sk_common.skc_dport); + if (dport == 0) + return 0; + + __u64 key = (__u64)(unsigned long)sk; + if (bpf_map_lookup_elem(&udp_seen, &key)) + return 0; /* already emitted this flow's birth */ + __u8 one = 1; + bpf_map_update_elem(&udp_seen, &key, &one, BPF_ANY); + + return emit(sk, IPPROTO_UDP, QIFTOP_BIRTH_DIR_OUTBOUND); +} + +/* --- inbound TCP: the freshly-accepted child sock --------------------- */ + +/* struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern) */ +SEC("fexit/inet_csk_accept") +int BPF_PROG(qiftop_inet_csk_accept, struct sock *sk, int flags, void *err, + int kern, struct sock *ret) +{ + /* ret is the freshly-accepted child sock; NULL on EAGAIN/interrupt. */ + return emit(ret, IPPROTO_TCP, QIFTOP_BIRTH_DIR_INBOUND); +} diff --git a/src/backend/linux/bpf/birth_events.h b/src/backend/linux/bpf/birth_events.h new file mode 100644 index 0000000..eb3bf97 --- /dev/null +++ b/src/backend/linux/bpf/birth_events.h @@ -0,0 +1,49 @@ +/* + * Shared wire struct between the eBPF program (birth.bpf.c, kernel side) and + * the userspace ring-buffer reader (backend/linux). Plain C, fixed-width, no + * Qt — both sides must agree byte-for-byte. + * + * The fixed-width `__u*` types come from whatever the includer pulled in first: + * vmlinux_min.h on the BPF side, on the userspace side. Keep + * this header free of its own includes so it composes with both. + */ +#ifndef QIFTOP_BIRTH_EVENTS_H +#define QIFTOP_BIRTH_EVENTS_H + +#define QIFTOP_BIRTH_COMM_LEN 16 +#define QIFTOP_BIRTH_ADDR_LEN 16 + +/* Mirrors qiftop::backend::Direction (src/backend/Connection.h). Birth knows + * the direction definitionally (connect = outbound, accept = inbound), unlike + * conntrack which infers it heuristically. */ +#define QIFTOP_BIRTH_DIR_UNKNOWN 0 +#define QIFTOP_BIRTH_DIR_OUTBOUND 1 +#define QIFTOP_BIRTH_DIR_INBOUND 2 + +/* + * One captured socket birth, pushed through the ring buffer. Addresses are RAW + * NETWORK-ORDER bytes (v4 occupies [0..4), v6 the full 16); ports are HOST + * order. `start_boottime_ns` is task->start_boottime (the field /proc//stat + * field 22 is derived from) — the userspace reader converts it to clock ticks + * for the PID-reuse guard (AGENTS.md §8a rule 2). + * + * Field order is size-descending to avoid implicit padding so the struct is + * identical under the BPF and host C ABIs without packing pragmas. + */ +struct qiftop_birth_event { + __u64 ts_ns; /* bpf_ktime_get_ns() at birth (CLOCK_MONOTONIC) */ + __u64 start_boottime_ns; /* task->start_boottime — PID-reuse guard */ + __u32 pid; /* tgid (the userspace-visible PID) */ + __u32 uid; /* real uid of the owning task */ + __u16 local_port; /* host byte order */ + __u16 remote_port; /* host byte order */ + __u8 proto; /* IPPROTO_TCP (6) / IPPROTO_UDP (17) */ + __u8 family; /* AF_INET (2) / AF_INET6 (10) */ + __u8 direction; /* QIFTOP_BIRTH_DIR_* */ + __u8 _pad; + __u8 local_addr[QIFTOP_BIRTH_ADDR_LEN]; /* network order, v4 in [0,4) */ + __u8 remote_addr[QIFTOP_BIRTH_ADDR_LEN]; + __u8 comm[QIFTOP_BIRTH_COMM_LEN]; /* kernel comm, NUL-padded */ +}; + +#endif /* QIFTOP_BIRTH_EVENTS_H */ diff --git a/src/backend/linux/bpf/vmlinux_min.h b/src/backend/linux/bpf/vmlinux_min.h new file mode 100644 index 0000000..6001e13 --- /dev/null +++ b/src/backend/linux/bpf/vmlinux_min.h @@ -0,0 +1,82 @@ +/* + * Minimal CO-RE type subset for birth.bpf.c. + * + * Hand-written instead of a multi-megabyte generated vmlinux.h, on purpose: the + * BUILD host then needs NO kernel BTF (CO-RE relocates these field accesses + * against the TARGET kernel's BTF at load time — which is required regardless), + * the header is arch-neutral, and it never drifts with the build box's kernel. + * + * Every record carries `preserve_access_index`, so each member access becomes a + * CO-RE relocation: the offsets declared here are IRRELEVANT — libbpf rewrites + * them per running kernel. We declare only the fields the program reads. + * Anonymous-union kernel members (skc_daddr, skc_dport, …) are declared flat; + * CO-RE resolves them by name against the target BTF, anonymous nesting and all. + * + * If this ever needs many more types, switch to a build-time + * `bpftool btf dump file /sys/kernel/btf/vmlinux format c` with a checked-in, + * dev-box-sanitised fallback. For this handful of fields, minimal wins. + */ +#ifndef QIFTOP_VMLINUX_MIN_H +#define QIFTOP_VMLINUX_MIN_H + +typedef unsigned char __u8; +typedef unsigned short __u16; +typedef unsigned int __u32; +typedef unsigned long long __u64; +typedef signed char __s8; +typedef short __s16; +typedef int __s32; +typedef long long __s64; +typedef __u16 __be16; +typedef __u32 __be32; +typedef __u32 __wsum; + +/* UAPI enum bpf_map_type values we use (stable ABI; see ). A full + * generated vmlinux.h would supply these — declared minimally here so the build + * host needs no kernel BTF. */ +enum { + BPF_MAP_TYPE_HASH = 1, + BPF_MAP_TYPE_LRU_HASH = 9, + BPF_MAP_TYPE_RINGBUF = 27, +}; + +/* bpf_map_update_elem() flags (UAPI). */ +enum { + BPF_ANY = 0, + BPF_NOEXIST = 1, + BPF_EXIST = 2, +}; + +#pragma clang attribute push(__attribute__((preserve_access_index)), apply_to = record) + +struct in6_addr { + union { + __u8 u6_addr8[16]; + } in6_u; +}; + +struct sock_common { + __be32 skc_daddr; + __be32 skc_rcv_saddr; + __be16 skc_dport; + __u16 skc_num; + unsigned short skc_family; + struct in6_addr skc_v6_daddr; + struct in6_addr skc_v6_rcv_saddr; +}; + +struct sock { + struct sock_common __sk_common; +}; + +struct task_struct { + /* /proc//stat field 22 is derived from start_BOOTTIME (see + * fs/proc/array.c: nsec_to_clock_t(timens_add_boottime_ns(start_boottime))), + * NOT start_time — capturing the wrong one makes the userspace PID-reuse + * guard mismatch every time. CO-RE relocates by name, so the name matters. */ + __u64 start_boottime; +}; + +#pragma clang attribute pop + +#endif /* QIFTOP_VMLINUX_MIN_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b3d729d..a0b3a39 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -279,6 +279,11 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") qiftop_add_test(test_proc_details test_proc_details.cpp ${CMAKE_SOURCE_DIR}/src/backend/linux/ProcDetails.cpp) + + # Pure decoder for the eBPF birth wire event (no kernel/libbpf). Always + # built on Linux — it needs only the shared wire header, not the skeleton. + qiftop_add_test(test_birth_decode + test_birth_decode.cpp) endif() # Integration test: spawns the actual qiftop-agent binary against a session diff --git a/tests/integration/vagrant/provision.sh b/tests/integration/vagrant/provision.sh index 3094716..6cfb7a4 100755 --- a/tests/integration/vagrant/provision.sh +++ b/tests/integration/vagrant/provision.sh @@ -15,6 +15,7 @@ apt-get install -y --no-install-recommends \ iproute2 iputils-ping netcat-openbsd \ docker.io \ podman netavark \ + clang libbpf-dev linux-tools-common linux-tools-generic \ rsync echo "==> ensure vagrant user can run docker without sudo" diff --git a/tests/test_birth_decode.cpp b/tests/test_birth_decode.cpp new file mode 100644 index 0000000..061f02f --- /dev/null +++ b/tests/test_birth_decode.cpp @@ -0,0 +1,119 @@ +#include + +#include + +#include +#include + +#include "backend/linux/BirthDecode.h" +#include "backend/linux/bpf/birth_events.h" + +using namespace qiftop::backend; +using namespace qiftop::backend::linuximpl; + +// Pure decode test for the eBPF birth wire event → (BirthKey, BirthRecord). No +// kernel, no libbpf — events are hand-built. Pins the byte-order/units contract +// the BpfBirthReader relies on (and that the PID-reuse guard depends on). +class TestBirthDecode : public QObject +{ + Q_OBJECT + + static qiftop_birth_event v4Tcp() + { + qiftop_birth_event e{}; + e.family = AF_INET; + e.proto = 6; // IPPROTO_TCP + e.direction = QIFTOP_BIRTH_DIR_OUTBOUND; + // 192.168.1.10 → :54321 -> 93.184.216.34 : 443 + e.local_addr[0] = 192; e.local_addr[1] = 168; e.local_addr[2] = 1; e.local_addr[3] = 10; + e.remote_addr[0] = 93; e.remote_addr[1] = 184; e.remote_addr[2] = 216; e.remote_addr[3] = 34; + e.local_port = 54321; + e.remote_port = 443; + e.pid = 4242; + e.uid = 1000; + std::memcpy(e.comm, "curl", 4); + e.start_boottime_ns = 12'300'000'000ULL; // *100/1e9 = 1230 ticks + e.ts_ns = 5'000'000'000ULL; // /1e6 = 5000 ms + return e; + } + +private slots: + void v4OutboundTcp() + { + const DecodedBirth d = decodeBirth(v4Tcp(), /*clkTck*/ 100, /*nowMonoMs*/ 999); + + QCOMPARE(d.key.proto, L4Proto::Tcp); + QCOMPARE(d.key.localAddress.toString(), QStringLiteral("192.168.1.10")); + QCOMPARE(d.key.remoteAddress.toString(), QStringLiteral("93.184.216.34")); + QCOMPARE(d.key.localPort, quint16(54321)); + QCOMPARE(d.key.remotePort, quint16(443)); + + QCOMPARE(d.rec.pid, qint32(4242)); + QCOMPARE(d.rec.uid, quint32(1000)); + QCOMPARE(d.rec.comm, QStringLiteral("curl")); + QCOMPARE(d.rec.direction, Direction::Outbound); + QCOMPARE(d.rec.startTime, quint64(1230)); // ns→ticks via USER_HZ=100 + QCOMPARE(d.rec.firstSeenMonoMs, qint64(5000)); + QCOMPARE(d.rec.insertedMonoMs, qint64(999)); + } + + void startTimeUsesClkTck() + { + // The conversion must track sysconf(_SC_CLK_TCK), not assume 100. + qiftop_birth_event e = v4Tcp(); + e.start_boottime_ns = 1'000'000'000ULL; // exactly 1 s of boottime + QCOMPARE(decodeBirth(e, 100, 0).rec.startTime, quint64(100)); + QCOMPARE(decodeBirth(e, 250, 0).rec.startTime, quint64(250)); + QCOMPARE(decodeBirth(e, 1000, 0).rec.startTime, quint64(1000)); + // Degenerate clkTck → 0 (guard then never false-rejects on starttime). + QCOMPARE(decodeBirth(e, 0, 0).rec.startTime, quint64(0)); + } + + void v6InboundTcp() + { + qiftop_birth_event e{}; + e.family = AF_INET6; + e.proto = 6; + e.direction = QIFTOP_BIRTH_DIR_INBOUND; + // 2001:db8::1 -> 2001:db8::2 + e.local_addr[0] = 0x20; e.local_addr[1] = 0x01; e.local_addr[2] = 0x0d; e.local_addr[3] = 0xb8; + e.local_addr[15] = 0x01; + e.remote_addr[0] = 0x20; e.remote_addr[1] = 0x01; e.remote_addr[2] = 0x0d; e.remote_addr[3] = 0xb8; + e.remote_addr[15] = 0x02; + e.local_port = 8443; + e.remote_port = 33000; + e.pid = 7; + std::memcpy(e.comm, "nginx", 5); + + const DecodedBirth d = decodeBirth(e, 100, 0); + QCOMPARE(d.key.localAddress, QHostAddress(QStringLiteral("2001:db8::1"))); + QCOMPARE(d.key.remoteAddress, QHostAddress(QStringLiteral("2001:db8::2"))); + QCOMPARE(d.key.localPort, quint16(8443)); + QCOMPARE(d.rec.direction, Direction::Inbound); + QCOMPARE(d.rec.comm, QStringLiteral("nginx")); + } + + void udpProtoAndUnknownDirection() + { + qiftop_birth_event e = v4Tcp(); + e.proto = 17; // IPPROTO_UDP + e.direction = QIFTOP_BIRTH_DIR_UNKNOWN; + const DecodedBirth d = decodeBirth(e, 100, 0); + QCOMPARE(d.key.proto, L4Proto::Udp); + QCOMPARE(d.rec.direction, Direction::Unknown); + } + + void commIsNulBoundedAndNeverOverreads() + { + // A comm with no NUL terminator in all 16 bytes must read at most 16. + qiftop_birth_event e = v4Tcp(); + std::memset(e.comm, 'a', QIFTOP_BIRTH_COMM_LEN); + const DecodedBirth d = decodeBirth(e, 100, 0); + QCOMPARE(d.rec.comm.size(), QIFTOP_BIRTH_COMM_LEN); + QVERIFY(std::all_of(d.rec.comm.begin(), d.rec.comm.end(), + [](QChar c) { return c == QLatin1Char('a'); })); + } +}; + +QTEST_APPLESS_MAIN(TestBirthDecode) +#include "test_birth_decode.moc" diff --git a/tests/test_bpf_birth_resolver.cpp b/tests/test_bpf_birth_resolver.cpp index 71dbe47..cd512df 100644 --- a/tests/test_bpf_birth_resolver.cpp +++ b/tests/test_bpf_birth_resolver.cpp @@ -94,13 +94,17 @@ private slots: QCOMPARE(r.cacheSize(), 0); } - void missingPidStarttimeIsRejected() + void goneShortLivedPidIsServed() { - // Probe returns 0 (pid gone / unreadable) → reject (can't confirm). + // Probe returns 0 (pid gone / unreadable). This is the PRIMARY hybrid + // case: the short-lived process has exited by the time the conntrack + // flow resolves, so /proc/ is gone — but the captured (pid, comm) + // is the historically-correct owner. Serve it (no live process holds + // the pid, so there is nothing to be confused with). BpfBirthResolver r([](qint32){ return quint64(0); }); r.setLoaded(true); r.onBirth(birthKeyOf(mkFlow(5000)), mkRec(4242, "curl", 99)); - QCOMPARE(r.resolvePid(mkFlow(5000)), qint32(0)); + QCOMPARE(r.resolvePid(mkFlow(5000)), qint32(4242)); } void cacheMissReturnsZero()