diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index cb1b38e1..7a56df2a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -263,6 +263,7 @@ The same backend publishes a socket snapshot every 5 seconds for the Host tab. T
**eBPF Mode (Default on Linux):**
- Uses kernel eBPF programs attached to socket syscalls
- Captures socket creation events with process context
+- On Linux 5.11+, runs a one-shot task-file iterator after attaching the live probes to capture owners of sockets that predate RustNet, including other users' sockets in file-capability mode
- Provides lower overhead than procfs scanning
- Records the group leader's TGID, the acting TID, and credentials; the name, executable path, and PPID are enriched in user space via procfs
- **Limitations:**
@@ -273,7 +274,8 @@ The same backend publishes a socket snapshot every 5 seconds for the Host tab. T
- Note: CAP_NET_ADMIN is NOT required (uses read-only, non-promiscuous packet capture)
**Fallback Behavior:**
-- If eBPF fails to load (permissions, kernel compatibility), automatically falls back to procfs mode
+- If the task-file iterator is unavailable, keeps the live eBPF tracker and uses the procfs startup inventory
+- If the live eBPF tracker fails to load, automatically falls back to procfs mode
- TUI Statistics panel shows active detection method
#### macOS
diff --git a/ARCHITECTURE.zh-CN.md b/ARCHITECTURE.zh-CN.md
index fcae1de8..49f66501 100644
--- a/ARCHITECTURE.zh-CN.md
+++ b/ARCHITECTURE.zh-CN.md
@@ -258,6 +258,7 @@ RustNet 使用平台特定的 API 将网络连接与进程关联。每次归属
**eBPF 模式(Linux 默认):**
- 使用附加到 socket 系统调用的内核 eBPF 程序
- 捕获带进程上下文的 socket 创建事件
+- 在 Linux 5.11 及更高版本上,先附加实时探针,再运行一次性的 task-file 迭代器,以捕获 RustNet 启动前已存在的 socket 所有者;使用文件 capabilities 运行时也包括其他用户的 socket
- 比 procfs 扫描开销更低
- 记录进程组组长的 TGID、当前线程的 TID 以及凭据;进程名、可执行路径和 PPID 在用户态通过 procfs 富化
- **局限性:**
@@ -268,7 +269,8 @@ RustNet 使用平台特定的 API 将网络连接与进程关联。每次归属
- 注意:不需要 CAP_NET_ADMIN(使用只读、非混杂包捕获)
**回退行为:**
-- 如果 eBPF 加载失败(权限、内核兼容性),自动回退到 procfs 模式
+- 如果 task-file 迭代器不可用,则保留实时 eBPF 追踪,并使用启动时的 procfs 清单
+- 如果实时 eBPF 追踪器加载失败,则自动回退到 procfs 模式
- TUI 统计面板显示当前使用的检测方法
#### macOS
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7234627..d428aad9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Default Npcap Installations on Windows**: RustNet now finds Npcap in its
standard `System32\Npcap` directory, so WinPcap API-compatible mode is no
longer required. `--help` and `--version` also work without Npcap installed
+- **Attribution of Pre-Existing Connections on Linux**: connections that were
+ already open before RustNet started keep their process name after privilege
+ reduction, including root services when RustNet runs with file capabilities
+ on Linux 5.11 and newer. A one-shot BPF task-file inventory and the
+ privileged procfs scan feed a validated fallback shown as the "startup
+ snapshot" match quality (#575)
### Removed
- **Ubuntu 25.10 (Questing) PPA**: the series reached end of life and
diff --git a/README.ja.md b/README.ja.md
index 8fd9694f..8c4724d5 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -18,6 +18,7 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ
## 主な機能
- TCP、UDP、QUIC 接続とプロセスの対応付け。詳細には PID、実行ファイル、ユーザー/グループ名、照合の信頼度、全プラットフォーム共通の親プロセスチェーン(上限あり)を表示
+- Linux 5.11 以降では、起動時の BPF task-file イテレーターにより、ファイル capabilities で実行した場合でも root や他ユーザーが所有する既存 socket を識別
- HTTP、TLS/SNI、DNS、SSH、QUIC、WireGuard、OpenVPN などの深層パケット解析
- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示
- Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示
diff --git a/README.md b/README.md
index 048794a7..b1e6ffea 100644
--- a/README.md
+++ b/README.md
@@ -63,8 +63,9 @@ RustNet uses kernel eBPF programs by default on Linux for enhanced performance a
- Short-lived processes that exit before this enrichment runs keep the eBPF-recorded 16-character name
**Fallback Behavior:**
+- On Linux 5.11 and newer, a one-shot BPF task-file iterator inventories sockets that were already open at startup, including sockets owned by root and other users when RustNet runs with file capabilities
- When eBPF fails to load or lacks sufficient permissions, RustNet automatically falls back to standard procfs-based process identification
-- Standard mode resolves names the same way via procfs scanning, but with higher CPU overhead
+- Older kernels and procfs-only builds resolve names through procfs scanning, which has higher CPU overhead and can only inspect socket owners visible to the RustNet user
- eBPF is enabled by default; no special build flags needed
To disable eBPF and use procfs-only mode, build with:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index f315a1b4..c18fc479 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -63,8 +63,9 @@ RustNet 在 Linux 上默认使用内核 eBPF 程序进行进程识别,从而
- 在该富化流程运行前就已退出的短命进程,仍保留 eBPF 记录的 16 字符名称
**回退行为:**
+- 在 Linux 5.11 及更高版本上,一次性的 BPF task-file 迭代器会清点 RustNet 启动前已经打开的 socket;使用文件 capabilities 运行时,也能识别 root 和其他用户拥有的 socket
- 当 eBPF 加载失败或权限不足时,RustNet 会自动回退到基于 procfs 的标准进程识别方式
-- 标准模式通过 procfs 扫描以同样的方式解析进程名,但 CPU 开销更高
+- 旧版内核和纯 procfs 构建通过 procfs 扫描解析进程名,CPU 开销更高,并且只能检查当前 RustNet 用户可见的 socket 所有者
- eBPF 默认启用,无需任何特殊编译参数
如需关闭 eBPF、仅使用 procfs 模式,请这样构建:
diff --git a/SECURITY.md b/SECURITY.md
index 6f0bd077..cf69abd8 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -66,10 +66,11 @@ If an attacker exploits a vulnerability in DPI/packet parsing:
Trade-off of the root uid drop: the procfs fallback for process attribution can
then only inspect processes owned by the target user, and Kubernetes log
-directories under `/var/log/pods` may become unreadable. The eBPF fast path
-(the default) is unaffected. If you rely on procfs-only attribution (e.g. a
-build without eBPF) and need to attribute other users' processes, use
-`--no-uid-drop`.
+directories under `/var/log/pods` may become unreadable. On Linux 5.11 and
+newer, the default eBPF path takes a one-shot task-file socket inventory before
+the drop, so pre-existing sockets owned by other users remain attributable. If
+you rely on procfs-only attribution, such as a build without eBPF or an older
+kernel, and need to attribute other users' processes, use `--no-uid-drop`.
### Graceful Degradation
@@ -276,6 +277,7 @@ When using eBPF for enhanced process detection (default on Linux):
- Requires additional kernel capabilities (`CAP_BPF`, `CAP_PERFMON`)
- eBPF programs are verified by kernel before loading
- Limited to read-only operations (no packet modification)
+- On Linux 5.11+, a one-shot task-file iterator inventories pre-existing socket owners
- Automatically falls back to procfs if eBPF fails
## Threat Model
diff --git a/SECURITY.zh-CN.md b/SECURITY.zh-CN.md
index 60acadea..874fddfb 100644
--- a/SECURITY.zh-CN.md
+++ b/SECURITY.zh-CN.md
@@ -65,9 +65,10 @@ RustNet 处理不受信任的网络数据,因此纵深防御至关重要。本
```
root uid 降权的权衡:降权后,procfs 回退路径的进程归属只能检查目标用户拥有的进程,
-`/var/log/pods` 下的 Kubernetes 日志目录也可能不可读。eBPF 快速路径(默认)不受影响。
-如果依赖纯 procfs 归属(如未启用 eBPF 的构建)且需要归属其他用户的进程,请使用
-`--no-uid-drop`。
+`/var/log/pods` 下的 Kubernetes 日志目录也可能不可读。在 Linux 5.11 及更高版本上,
+默认 eBPF 路径会在降权前通过一次性的 task-file 迭代器清点 socket,因此其他用户拥有的
+既有 socket 仍可归属。如果依赖纯 procfs 归属(如未启用 eBPF 的构建或旧版内核)且需要
+归属其他用户的进程,请使用 `--no-uid-drop`。
### 优雅降级
@@ -271,6 +272,7 @@ RustNet 完全在本地运行:
- 现代内核需要额外的 Linux capabilities(`CAP_BPF`、`CAP_PERFMON`)
- eBPF 程序在加载前由内核验证
- 仅限只读操作(不修改数据包)
+- 在 Linux 5.11 及更高版本上,一次性的 task-file 迭代器会清点既有 socket 的所有者
- 如果 eBPF 失败,自动回退到 procfs
## 威胁模型
diff --git a/crates/rustnet-core/src/network/types/identity.rs b/crates/rustnet-core/src/network/types/identity.rs
index a7b2f14c..e17fee13 100644
--- a/crates/rustnet-core/src/network/types/identity.rs
+++ b/crates/rustnet-core/src/network/types/identity.rs
@@ -30,6 +30,10 @@ pub enum MatchQuality {
ProcfsExact,
/// procfs match that needed a relaxed key.
ProcfsRelaxed,
+ /// Matched the validated socket-table snapshot taken at startup. The
+ /// owner came from BPF or a privileged procfs scan, and both the socket
+ /// inode and process identity still match their startup values.
+ StartupSnapshot,
/// The backend reported an owner but could not report match provenance.
Unspecified,
}
@@ -48,6 +52,7 @@ impl MatchQuality {
Self::ListenerSocket => "listener socket",
Self::ProcfsExact => "procfs exact",
Self::ProcfsRelaxed => "procfs relaxed",
+ Self::StartupSnapshot => "startup snapshot",
Self::Unspecified => "unspecified",
}
}
@@ -65,6 +70,7 @@ impl MatchQuality {
Self::ListenerSocket => "listener-socket",
Self::ProcfsExact => "procfs-exact",
Self::ProcfsRelaxed => "procfs-relaxed",
+ Self::StartupSnapshot => "startup-snapshot",
Self::Unspecified => "unspecified",
}
}
@@ -379,6 +385,7 @@ mod tests {
(MatchQuality::ListenerSocket, "listener-socket"),
(MatchQuality::ProcfsExact, "procfs-exact"),
(MatchQuality::ProcfsRelaxed, "procfs-relaxed"),
+ (MatchQuality::StartupSnapshot, "startup-snapshot"),
(MatchQuality::Unspecified, "unspecified"),
];
@@ -391,6 +398,7 @@ mod tests {
assert!(MatchQuality::ExactTuple.is_exact());
assert!(MatchQuality::ProcfsExact.is_exact());
assert!(!MatchQuality::ProcfsRelaxed.is_exact());
+ assert!(!MatchQuality::StartupSnapshot.is_exact());
assert!(!MatchQuality::Unspecified.is_exact());
}
diff --git a/crates/rustnet-host/README.md b/crates/rustnet-host/README.md
index d530872c..e8745020 100644
--- a/crates/rustnet-host/README.md
+++ b/crates/rustnet-host/README.md
@@ -76,10 +76,13 @@ When a platform can't use its optimal method, `ProcessLookup::get_degradation_re
reports why (e.g. missing `CAP_BPF`, no root for PKTAP) via `DegradationReason`,
which front-ends can surface to the user.
-Both Linux BPF objects use CO-RE for safe socket field access and therefore
+All Linux BPF objects use CO-RE for safe kernel field access and therefore
require usable target BTF. A compatible target-BTF kernel tries fentry/fexit
-first and legacy kprobes second. A kernel without usable target BTF falls
-directly to procfs rather than relying on fixed structure offsets.
+first and legacy kprobes second. On Linux 5.11 and newer, a separate one-shot
+task-file iterator captures socket owners that predate the live probes. Its
+failure leaves the selected live tracker running and falls back to the procfs
+startup inventory. A kernel without usable target BTF falls directly to procfs
+rather than relying on fixed structure offsets.
## Linux eBPF integration matrix
@@ -99,6 +102,11 @@ sudo -E cargo test -p rustnet-host --features ebpf -- \
--ignored --exact \
linux::ebpf::tracker_libbpf::integration_tests::legacy_kprobe_socket_attribution_matrix \
--nocapture
+
+sudo -E cargo test -p rustnet-host --features ebpf -- \
+ --ignored --exact \
+ linux::ebpf::task_file::tests::kernel_iterator_reports_current_process_socket \
+ --nocapture
```
## Scope
diff --git a/crates/rustnet-host/build.rs b/crates/rustnet-host/build.rs
index 9a0d9680..c24a497a 100644
--- a/crates/rustnet-host/build.rs
+++ b/crates/rustnet-host/build.rs
@@ -59,11 +59,11 @@ fn compile_ebpf_programs() {
let vmlinux_include_path =
get_vmlinux_header(vmlinux_arch).expect("Failed to locate bundled vmlinux.h");
- for backend in ["fentry", "kprobe"] {
- let src = format!("src/linux/ebpf/programs/socket_tracker_{backend}.bpf.c");
- let out = out_dir.join(format!("socket_tracker_{backend}.skel.rs"));
+ for program in ["fentry", "kprobe", "task_file"] {
+ let src = format!("src/linux/ebpf/programs/socket_tracker_{program}.bpf.c");
+ let out = out_dir.join(format!("socket_tracker_{program}.skel.rs"));
- println!("cargo:warning=Building eBPF {backend} backend using libbpf-cargo");
+ println!("cargo:warning=Building eBPF {program} program using libbpf-cargo");
SkeletonBuilder::new()
.source(&src)
diff --git a/crates/rustnet-host/src/linux/ebpf/mod.rs b/crates/rustnet-host/src/linux/ebpf/mod.rs
index 3251412e..9f95bded 100644
--- a/crates/rustnet-host/src/linux/ebpf/mod.rs
+++ b/crates/rustnet-host/src/linux/ebpf/mod.rs
@@ -5,8 +5,10 @@
mod loader;
mod maps_libbpf;
+mod task_file;
mod tracker_libbpf;
+pub(super) use task_file::snapshot_task_file_owners;
pub(super) use tracker_libbpf::LibbpfSocketTracker as EbpfSocketTracker;
use crate::MatchQuality;
diff --git a/crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c b/crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c
new file mode 100644
index 00000000..cf324bde
--- /dev/null
+++ b/crates/rustnet-host/src/linux/ebpf/programs/socket_tracker_task_file.bpf.c
@@ -0,0 +1,53 @@
+#include "vmlinux.h"
+
+#include
+#include
+
+#define TASK_COMM_LEN 16
+#define TASK_FILE_OWNER_SIZE 32
+
+/* Binary record consumed by task_file.rs. */
+struct task_file_owner
+{
+ __u64 inode;
+ __u32 tgid;
+ __u32 uid;
+ char comm[TASK_COMM_LEN];
+};
+
+_Static_assert(sizeof(struct task_file_owner) == TASK_FILE_OWNER_SIZE,
+ "task-file owner ABI size changed");
+
+/*
+ * One-shot startup inventory for sockets that existed before the live
+ * fentry/kprobe programs were attached. The kernel's task_file iterator skips
+ * threads that share their group leader's file table, so normal thread groups
+ * do not emit duplicate owners.
+ */
+SEC("iter/task_file")
+int snapshot_task_file_owners(struct bpf_iter__task_file *ctx)
+{
+ struct task_struct *task = ctx->task;
+ struct file *file = ctx->file;
+
+ if (!task || !file || !bpf_sock_from_file(file))
+ return 0;
+
+ struct task_file_owner owner = {};
+ owner.inode = BPF_CORE_READ(file, f_inode, i_ino);
+ owner.tgid = BPF_CORE_READ(task, tgid);
+ owner.uid = BPF_CORE_READ(task, cred, euid.val);
+
+ struct task_struct *leader = BPF_CORE_READ(task, group_leader);
+ if (!leader || owner.inode == 0 || owner.tgid == 0)
+ return 0;
+
+ if (BPF_CORE_READ_STR_INTO(&owner.comm, leader, comm) <= 0 ||
+ owner.comm[0] == '\0')
+ return 0;
+
+ bpf_seq_write(ctx->meta->seq, &owner, sizeof(owner));
+ return 0;
+}
+
+char LICENSE[] SEC("license") = "Dual BSD/GPL";
diff --git a/crates/rustnet-host/src/linux/ebpf/task_file.rs b/crates/rustnet-host/src/linux/ebpf/task_file.rs
new file mode 100644
index 00000000..4fed37b3
--- /dev/null
+++ b/crates/rustnet-host/src/linux/ebpf/task_file.rs
@@ -0,0 +1,176 @@
+//! One-shot BPF task-file iterator for sockets that predate RustNet startup.
+
+use crate::SocketOwner;
+use crate::linux::process::StartupSocketOwners;
+use anyhow::{Context, Result, ensure};
+use libbpf_rs::skel::{OpenSkel, SkelBuilder};
+use libbpf_rs::{Iter, IterOpts};
+use std::io::Read;
+
+mod socket_tracker_task_file {
+ include!(concat!(
+ env!("OUT_DIR"),
+ "/socket_tracker_task_file.skel.rs"
+ ));
+}
+
+use socket_tracker_task_file::*;
+
+const TASK_COMM_LEN: usize = 16;
+const OWNER_RECORD_SIZE: usize = 32;
+const READ_BUFFER_SIZE: usize = 64 * 1024;
+
+pub(in crate::linux) fn snapshot_task_file_owners() -> Result {
+ let skel_builder = SocketTrackerTaskFileSkelBuilder::default();
+ let mut open_object = Box::new(std::mem::MaybeUninit::uninit());
+ let open_skel = skel_builder
+ .open(&mut open_object)
+ .context("open task-file iterator skeleton")?;
+ let skel = open_skel
+ .load()
+ .context("load task-file iterator BPF object")?;
+ let link = skel
+ .progs
+ .snapshot_task_file_owners
+ .attach_iter_with_opts(IterOpts::None)
+ .context("attach task-file iterator")?;
+ let mut iter = Iter::new(&link).context("create task-file iterator reader")?;
+
+ read_owner_records(&mut iter)
+}
+
+fn read_owner_records(reader: &mut impl Read) -> Result {
+ let mut owners = StartupSocketOwners::default();
+ let mut pending = Vec::with_capacity(READ_BUFFER_SIZE + OWNER_RECORD_SIZE);
+ let mut buffer = [0_u8; READ_BUFFER_SIZE];
+
+ loop {
+ let bytes_read = reader
+ .read(&mut buffer)
+ .context("read task-file iterator records")?;
+ if bytes_read == 0 {
+ break;
+ }
+
+ pending.extend_from_slice(&buffer[..bytes_read]);
+ let complete_len = pending.len() / OWNER_RECORD_SIZE * OWNER_RECORD_SIZE;
+ let (records, remainder) = pending[..complete_len].as_chunks::();
+ debug_assert!(remainder.is_empty());
+ for record in records {
+ if let Some((inode, owner)) = decode_owner_record(record) {
+ owners.insert(inode, owner);
+ }
+ }
+
+ let remaining = pending.len() - complete_len;
+ pending.copy_within(complete_len.., 0);
+ pending.truncate(remaining);
+ }
+
+ ensure!(
+ pending.is_empty(),
+ "task-file iterator returned a truncated owner record"
+ );
+ Ok(owners)
+}
+
+fn decode_owner_record(record: &[u8]) -> Option<(u64, SocketOwner)> {
+ if record.len() != OWNER_RECORD_SIZE {
+ return None;
+ }
+
+ let inode = u64::from_ne_bytes(record[0..8].try_into().ok()?);
+ let tgid = u32::from_ne_bytes(record[8..12].try_into().ok()?);
+ let uid = u32::from_ne_bytes(record[12..16].try_into().ok()?);
+ let comm_bytes = &record[16..16 + TASK_COMM_LEN];
+ let comm_len = comm_bytes
+ .iter()
+ .position(|&byte| byte == 0)
+ .unwrap_or(TASK_COMM_LEN);
+ let comm = String::from_utf8_lossy(&comm_bytes[..comm_len]).to_string();
+
+ if inode == 0 || tgid == 0 || comm.is_empty() {
+ return None;
+ }
+
+ Some((inode, SocketOwner::new(tgid, comm, Some(uid))))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Cursor;
+ use std::net::TcpListener;
+ use std::os::fd::AsRawFd;
+
+ fn record(inode: u64, tgid: u32, uid: u32, comm: &str) -> [u8; OWNER_RECORD_SIZE] {
+ let mut bytes = [0_u8; OWNER_RECORD_SIZE];
+ bytes[0..8].copy_from_slice(&inode.to_ne_bytes());
+ bytes[8..12].copy_from_slice(&tgid.to_ne_bytes());
+ bytes[12..16].copy_from_slice(&uid.to_ne_bytes());
+ let comm = comm.as_bytes();
+ let len = comm.len().min(TASK_COMM_LEN - 1);
+ bytes[16..16 + len].copy_from_slice(&comm[..len]);
+ bytes
+ }
+
+ #[test]
+ fn decodes_binary_owner_record() {
+ let bytes = record(123, 456, 789, "nordvpnd");
+ let (inode, owner) = decode_owner_record(&bytes).expect("record must decode");
+
+ assert_eq!(inode, 123);
+ assert_eq!(owner, SocketOwner::new(456, "nordvpnd", Some(789)));
+ }
+
+ #[test]
+ fn reads_records_split_across_io_boundaries() {
+ struct SplitReader {
+ bytes: Cursor>,
+ chunk_size: usize,
+ }
+
+ impl Read for SplitReader {
+ fn read(&mut self, buf: &mut [u8]) -> std::io::Result {
+ let len = buf.len().min(self.chunk_size);
+ self.bytes.read(&mut buf[..len])
+ }
+ }
+
+ let bytes = [record(1, 10, 100, "one"), record(2, 20, 200, "two")].concat();
+ let mut reader = SplitReader {
+ bytes: Cursor::new(bytes),
+ chunk_size: 7,
+ };
+
+ let owners = read_owner_records(&mut reader).expect("records must decode");
+ assert_eq!(owners.len(), 2);
+ }
+
+ #[test]
+ fn rejects_truncated_stream() {
+ let mut bytes = record(1, 10, 100, "one").to_vec();
+ bytes.pop();
+
+ let error = read_owner_records(&mut Cursor::new(bytes)).unwrap_err();
+ assert!(error.to_string().contains("truncated owner record"));
+ }
+
+ #[test]
+ #[ignore = "requires CAP_BPF, CAP_PERFMON, and a kernel with task-file iterators"]
+ fn kernel_iterator_reports_current_process_socket() {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("listener must bind");
+ let link = std::fs::read_link(format!("/proc/self/fd/{}", listener.as_raw_fd()))
+ .expect("listener fd must resolve");
+ let link = link.to_str().expect("socket link must be UTF-8");
+ let inode = link
+ .strip_prefix("socket:[")
+ .and_then(|value| value.strip_suffix(']'))
+ .and_then(|value| value.parse::().ok())
+ .expect("socket link must contain an inode");
+
+ let owners = snapshot_task_file_owners().expect("task-file iterator must load");
+ let owner = owners.get(inode).expect("listener owner must be present");
+ assert_eq!(owner.pid, std::process::id());
+ }
+}
diff --git a/crates/rustnet-host/src/linux/enhanced.rs b/crates/rustnet-host/src/linux/enhanced.rs
index 8d532f02..c9f25662 100644
--- a/crates/rustnet-host/src/linux/enhanced.rs
+++ b/crates/rustnet-host/src/linux/enhanced.rs
@@ -13,7 +13,7 @@ use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{Duration, Instant};
-use super::ebpf::EbpfSocketTracker;
+use super::ebpf::{EbpfSocketTracker, snapshot_task_file_owners};
use crate::linux::ebpf::SocketMatch;
use crate::linux::process::{refine_truncated_name, resolve_executable, resolve_parent_pid};
use rustnet_core::network::types::ProtocolState;
@@ -54,7 +54,6 @@ impl Default for CleanupConfig {
impl EnhancedLinuxProcessLookup {
pub(super) fn new() -> Result {
let cleanup_config = CleanupConfig::default();
- let procfs_lookup = LinuxProcessLookup::new()?;
let (ebpf_tracker, degradation_reason) = match EbpfSocketTracker::new() {
Ok((tracker_opt, reason)) => {
@@ -77,6 +76,33 @@ impl EnhancedLinuxProcessLookup {
}
};
+ // Attach the live tracker before taking the one-shot task-file
+ // inventory. Connections created during or after the inventory are
+ // then covered by fentry/kprobe even if the iterator does not visit
+ // them. Keep the iterator in a separate BPF object so an older kernel
+ // can reject it without disabling the live tracker.
+ let startup_owners = if ebpf_tracker.is_some() {
+ match snapshot_task_file_owners() {
+ Ok(owners) => {
+ info!(
+ "eBPF task-file startup snapshot found owners for {} socket inodes",
+ owners.len()
+ );
+ owners
+ }
+ Err(error) => {
+ info!(
+ "eBPF task-file startup snapshot unavailable: {}; using procfs ownership",
+ error
+ );
+ Default::default()
+ }
+ }
+ } else {
+ Default::default()
+ };
+ let procfs_lookup = LinuxProcessLookup::new_with_bpf_startup_owners(startup_owners)?;
+
Ok(Self {
ebpf_tracker: RwLock::new(ebpf_tracker),
procfs_lookup,
diff --git a/crates/rustnet-host/src/linux/process.rs b/crates/rustnet-host/src/linux/process.rs
index 411a1b5e..d0e82d6d 100644
--- a/crates/rustnet-host/src/linux/process.rs
+++ b/crates/rustnet-host/src/linux/process.rs
@@ -7,7 +7,7 @@ use crate::{
};
use anyhow::Result;
use rustnet_core::network::types::{Connection, Protocol};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet, hash_map::Entry};
use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::os::unix::fs::MetadataExt;
@@ -147,6 +147,61 @@ fn resolve_process_lineage(tgid: u32, ppid: u32) -> Option {
/// Map of socket inode to its best-effort process owner.
type InodeProcessMap = HashMap;
+
+/// Socket owners discovered before the procfs connection tables are parsed.
+///
+/// The BPF task-file iterator and procfs may both report an inode. Repeated
+/// reports from the same TGID are harmless, but fork and fd passing let
+/// several processes hold one socket: a pre-forking server's workers all
+/// carry the listening inode their master opened. Those inodes keep a
+/// best-effort owner for the live table, chosen by lowest PID so that
+/// /proc iteration order cannot change the answer between refreshes, and are
+/// reported separately as shared so the startup snapshot can skip them.
+#[derive(Debug, Default)]
+pub(super) struct StartupSocketOwners {
+ owners: InodeProcessMap,
+ shared: HashSet,
+}
+
+impl StartupSocketOwners {
+ pub(super) fn insert(&mut self, inode: u64, owner: SocketOwner) {
+ if inode == 0 {
+ return;
+ }
+
+ match self.owners.entry(inode) {
+ Entry::Vacant(entry) => {
+ entry.insert(owner);
+ }
+ // The same process seen twice: prefer the later report, which is
+ // the procfs scan refreshing what BPF recorded first.
+ Entry::Occupied(mut entry) if entry.get().pid == owner.pid => {
+ entry.insert(owner);
+ }
+ Entry::Occupied(mut entry) => {
+ self.shared.insert(inode);
+ if owner.pid < entry.get().pid {
+ entry.insert(owner);
+ }
+ }
+ }
+ }
+
+ pub(super) fn len(&self) -> usize {
+ self.owners.len()
+ }
+
+ #[cfg(test)]
+ pub(super) fn get(&self, inode: u64) -> Option<&SocketOwner> {
+ self.owners.get(&inode)
+ }
+
+ /// The owner map for the live socket table, plus the inodes held by more
+ /// than one process.
+ fn into_parts(self) -> (InodeProcessMap, HashSet) {
+ (self.owners, self.shared)
+ }
+}
/// Map of PID to process name
#[cfg(feature = "ebpf")]
type PidNameMap = HashMap;
@@ -155,6 +210,95 @@ type PidNameMap = ();
/// Map of connection key to (PID, process name)
type ConnectionProcessMap = HashMap;
+/// Owner recorded by the BPF or privileged procfs startup scan for one
+/// pre-existing socket, keyed by its exact 4-tuple. The inode pins the
+/// attribution to the socket object itself, not merely the tuple.
+#[derive(Debug, Clone)]
+struct SnapshotOwner {
+ pid: u32,
+ name: String,
+ inode: u64,
+ /// Process start time in clock ticks, when it was readable at startup.
+ /// `comm` alone cannot survive PID reuse because any process may rename
+ /// itself with `prctl(PR_SET_NAME)`; the start time cannot be forged.
+ start_ticks: Option,
+}
+
+/// Process start time in clock ticks, stable for the life of the process.
+fn process_start_ticks(pid: u32) -> Option {
+ let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
+ Some(parse_proc_stat(&stat)?.start_ticks)
+}
+
+/// Build the startup fallback table from the startup socket inventory,
+/// pairing each owner with the inode of its own row so the two can never
+/// come from different sockets. A tuple occupied by more than one socket
+/// (SO_REUSEPORT plus connected UDP makes exact duplicates legal) is
+/// dropped entirely, even when only one of its rows resolved an owner:
+/// observed traffic cannot be assigned to either socket. So is a socket held
+/// by several processes, whose single owner is a best-effort pick the live
+/// table can live with but a long-lived fallback should not freeze. Rows
+/// without an inode or a resolved owner contribute no entry of their own.
+fn build_startup_snapshot(
+ sockets: &SocketSnapshot,
+ shared_inodes: &HashSet,
+) -> HashMap {
+ let key_of = |socket: &HostSocket| {
+ socket.remote_addr.map(|remote_addr| ConnectionKey {
+ protocol: socket.protocol,
+ local_addr: socket.local_addr,
+ remote_addr,
+ })
+ };
+
+ let mut occupancy: HashMap = HashMap::new();
+ for socket in sockets.sockets.iter() {
+ if let Some(key) = key_of(socket) {
+ *occupancy.entry(key).or_insert(0) += 1;
+ }
+ }
+
+ let mut snapshot = HashMap::new();
+ for socket in sockets.sockets.iter() {
+ let (Some(key), Some(inode), Some(owner)) =
+ (key_of(socket), socket.native_id, socket.owner.as_ref())
+ else {
+ continue;
+ };
+ if occupancy.get(&key) != Some(&1) || shared_inodes.contains(&inode) {
+ continue;
+ }
+ snapshot.insert(
+ key,
+ SnapshotOwner {
+ pid: owner.pid,
+ name: owner.name.clone(),
+ inode,
+ start_ticks: process_start_ticks(owner.pid),
+ },
+ );
+ }
+ snapshot
+}
+
+/// Whether a startup-snapshot entry's recorded owner is still the same
+/// process. `/proc/` stays world-readable after the uid drop, so both
+/// the name captured at startup and, when it was readable, the process start
+/// time must still match. This rejects owners that have exited and PIDs
+/// since reused by a different program, including one that renamed itself to
+/// impersonate the original.
+fn snapshot_owner_still_matches(owner: &SnapshotOwner) -> bool {
+ let name_matches = fs::read_to_string(format!("/proc/{}/comm", owner.pid))
+ .map(|comm| comm.trim() == owner.name)
+ .unwrap_or(false);
+
+ name_matches
+ && match owner.start_ticks {
+ Some(start_ticks) => process_start_ticks(owner.pid) == Some(start_ticks),
+ None => true,
+ }
+}
+
fn parse_proc_tcp_state(value: &str) -> HostTcpState {
match value {
"01" => HostTcpState::Established,
@@ -175,6 +319,14 @@ fn parse_proc_tcp_state(value: &str) -> HostTcpState {
pub(super) struct LinuxProcessLookup {
// Cache: ConnectionKey -> (pid, process_name)
cache: RwLock,
+ // The socket table as scanned at startup, while the process still had
+ // its full privileges. After the uid drop, rescans only see the drop
+ // target's own /proc//fd entries, so connections that already
+ // existed at launch (other users' daemons, root services) would lose
+ // their owner on the first refresh. This immutable snapshot keeps them
+ // attributable. The live cache always wins; the snapshot only fills
+ // holes, so a reused 4-tuple visible to the rescan is never shadowed.
+ startup_snapshot: HashMap,
// Cache: PID -> process_name (for resolving eBPF thread names to main process names)
#[cfg(feature = "ebpf")]
pid_names: RwLock>,
@@ -186,11 +338,22 @@ pub(super) struct LinuxProcessLookup {
impl LinuxProcessLookup {
pub(super) fn new() -> Result {
+ Self::new_with_startup_socket_owners(StartupSocketOwners::default())
+ }
+
+ #[cfg(feature = "ebpf")]
+ pub(super) fn new_with_bpf_startup_owners(owners: StartupSocketOwners) -> Result {
+ Self::new_with_startup_socket_owners(owners)
+ }
+
+ fn new_with_startup_socket_owners(owners: StartupSocketOwners) -> Result {
// Populate the cache immediately so early connections have process names available.
// This ensures the PID→name cache is ready before packet capture starts.
- let (process_map, _pid_names, socket_snapshot) = Self::build_process_map()?;
+ let (process_map, _pid_names, socket_snapshot, shared_inodes) =
+ Self::build_process_map(owners)?;
Ok(Self {
+ startup_snapshot: build_startup_snapshot(&socket_snapshot, &shared_inodes),
cache: RwLock::new(process_map),
#[cfg(feature = "ebpf")]
pid_names: RwLock::new(_pid_names),
@@ -204,6 +367,7 @@ impl LinuxProcessLookup {
#[cfg(test)]
fn with_socket_table(lookup: ConnectionProcessMap) -> Self {
Self {
+ startup_snapshot: HashMap::new(),
cache: RwLock::new(lookup),
#[cfg(feature = "ebpf")]
pid_names: RwLock::new(HashMap::new()),
@@ -268,8 +432,44 @@ impl LinuxProcessLookup {
// shape is deliberately collapsed into a single `ProcfsRelaxed`: what
// matters downstream is that procfs needed to guess, not which of the
// three wildcard shapes it guessed with.
- let ((pid, name), _shape) = relaxed_lookup(&cache, &key)?;
- Some((*pid, name.clone(), MatchQuality::ProcfsRelaxed))
+ if let Some(((pid, name), _shape)) = relaxed_lookup(&cache, &key) {
+ return Some((*pid, name.clone(), MatchQuality::ProcfsRelaxed));
+ }
+ drop(cache);
+
+ // Last resort: the BPF or privileged procfs startup snapshot, for
+ // connections that already existed at launch but whose owner the
+ // post-uid-drop rescan can no longer see. Exact 4-tuple hits only:
+ // relaxed matching against the snapshot would let a stale listener
+ // entry claim new
+ // inbound connections indefinitely. The hit is only trusted while
+ // (a) the very same socket, by inode, still occupies the tuple in
+ // the periodically refreshed socket inventory (which stays readable
+ // after the uid drop even when owners do not), so a closed-and-
+ // reused tuple is rejected; and (b) the recorded owner is
+ // verifiably still the same process. The refresh cadence bounds the
+ // reuse-detection window to one refresh interval.
+ let owner = self.startup_snapshot.get(&key)?;
+ if self.snapshot_socket_unchanged(&key, owner.inode) && snapshot_owner_still_matches(owner)
+ {
+ return Some((owner.pid, owner.name.clone(), MatchQuality::StartupSnapshot));
+ }
+ None
+ }
+
+ /// Whether the current socket inventory still shows the startup socket
+ /// (same inode) on this exact tuple.
+ fn snapshot_socket_unchanged(&self, key: &ConnectionKey, inode: u64) -> bool {
+ let snapshot = self
+ .socket_snapshot
+ .read()
+ .expect("socket snapshot lock poisoned");
+ snapshot.sockets.iter().any(|socket| {
+ socket.native_id == Some(inode)
+ && socket.protocol == key.protocol
+ && socket.local_addr == key.local_addr
+ && socket.remote_addr == Some(key.remote_addr)
+ })
}
/// Resolve a process's parent chain, memoized per TGID until the next
@@ -308,12 +508,19 @@ impl LinuxProcessLookup {
}
/// Build connection -> process mapping and PID -> name mapping
- fn build_process_map() -> Result<(ConnectionProcessMap, PidNameMap, SocketSnapshot)> {
+ fn build_process_map(
+ startup_owners: StartupSocketOwners,
+ ) -> Result<(
+ ConnectionProcessMap,
+ PidNameMap,
+ SocketSnapshot,
+ HashSet,
+ )> {
let mut process_map = HashMap::new();
let mut sockets = Vec::new();
// First, build inode -> process mapping and PID -> name mapping
- let (inode_to_process, pid_names) = Self::build_inode_map()?;
+ let (inode_to_process, pid_names, shared_inodes) = Self::build_inode_map(startup_owners)?;
// Then, parse network files to map connections -> inodes -> processes
Self::parse_and_map(
@@ -345,14 +552,24 @@ impl LinuxProcessLookup {
&mut sockets,
)?;
- Ok((process_map, pid_names, SocketSnapshot::new(sockets)))
+ Ok((
+ process_map,
+ pid_names,
+ SocketSnapshot::new(sockets),
+ shared_inodes,
+ ))
}
/// Build inode -> (pid, process_name) mapping and PID -> process_name mapping
- fn build_inode_map() -> Result<(InodeProcessMap, PidNameMap)> {
- let mut inode_map = HashMap::new();
+ fn build_inode_map(
+ mut startup_owners: StartupSocketOwners,
+ ) -> Result<(InodeProcessMap, PidNameMap, HashSet)> {
#[cfg(feature = "ebpf")]
- let mut pid_names = HashMap::new();
+ let mut pid_names = startup_owners
+ .owners
+ .values()
+ .map(|owner| (owner.pid, owner.name.clone()))
+ .collect::>();
#[cfg(not(feature = "ebpf"))]
let pid_names = ();
@@ -388,7 +605,7 @@ impl LinuxProcessLookup {
&& let Some(link_str) = link.to_str()
&& let Some(inode) = Self::extract_socket_inode(link_str)
{
- inode_map.insert(
+ startup_owners.insert(
inode,
SocketOwner {
pid,
@@ -402,7 +619,8 @@ impl LinuxProcessLookup {
}
}
- Ok((inode_map, pid_names))
+ let (inode_map, shared_inodes) = startup_owners.into_parts();
+ Ok((inode_map, pid_names, shared_inodes))
}
/// Parse /proc/net file and map connections to processes
@@ -418,6 +636,17 @@ impl LinuxProcessLookup {
Err(_) => return Ok(()), // File might not exist
};
+ Self::parse_and_map_content(&content, protocol, inode_map, result, sockets);
+ Ok(())
+ }
+
+ fn parse_and_map_content(
+ content: &str,
+ protocol: Protocol,
+ inode_map: &InodeProcessMap,
+ result: &mut ConnectionProcessMap,
+ sockets: &mut Vec,
+ ) {
for (i, line) in content.lines().enumerate() {
if i == 0 {
continue; // Skip header
@@ -468,8 +697,6 @@ impl LinuxProcessLookup {
native_id: inode,
});
}
-
- Ok(())
}
fn parse_hex_address(hex_addr: &str) -> Option {
@@ -518,7 +745,8 @@ impl ProcessLookup for LinuxProcessLookup {
}
fn refresh(&self) -> Result<()> {
- let (process_map, _pid_names, socket_snapshot) = Self::build_process_map()?;
+ let (process_map, _pid_names, socket_snapshot, _shared_inodes) =
+ Self::build_process_map(StartupSocketOwners::default())?;
*self.cache.write().expect("process cache lock poisoned") = process_map;
*self
@@ -572,6 +800,90 @@ mod tests {
}
}
+ #[test]
+ fn startup_socket_owners_report_a_shared_inode_without_losing_its_owner() {
+ // A pre-forking server: the master opened the listening socket and
+ // every worker inherited it. Dropping the inode would leave the
+ // listener ownerless in the live table, so the lowest PID (the
+ // master) wins and the inode is reported as shared instead.
+ let mut owners = StartupSocketOwners::default();
+ owners.insert(42, SocketOwner::new(1001, "nginx", Some(33)));
+ owners.insert(42, SocketOwner::new(1000, "nginx", Some(0)));
+ owners.insert(42, SocketOwner::new(1002, "nginx", Some(33)));
+
+ assert_eq!(
+ owners.get(42),
+ Some(&SocketOwner::new(1000, "nginx", Some(0)))
+ );
+
+ let (inode_map, shared) = owners.into_parts();
+ assert_eq!(
+ inode_map.get(&42),
+ Some(&SocketOwner::new(1000, "nginx", Some(0)))
+ );
+ assert!(shared.contains(&42));
+ }
+
+ #[test]
+ fn startup_socket_owners_pick_a_shared_inode_owner_independent_of_scan_order() {
+ // /proc iteration order must not change the answer between refreshes.
+ let mut forward = StartupSocketOwners::default();
+ for pid in [1000, 1001, 1002] {
+ forward.insert(42, SocketOwner::new(pid, "nginx", Some(0)));
+ }
+ let mut reverse = StartupSocketOwners::default();
+ for pid in [1002, 1001, 1000] {
+ reverse.insert(42, SocketOwner::new(pid, "nginx", Some(0)));
+ }
+
+ assert_eq!(forward.get(42).map(|owner| owner.pid), Some(1000));
+ assert_eq!(reverse.get(42).map(|owner| owner.pid), Some(1000));
+ }
+
+ #[test]
+ fn startup_socket_owners_collapse_same_process_duplicates() {
+ let mut owners = StartupSocketOwners::default();
+ owners.insert(42, SocketOwner::new(10, "old-name", None));
+ owners.insert(42, SocketOwner::new(10, "current-name", Some(1000)));
+
+ assert_eq!(
+ owners.get(42),
+ Some(&SocketOwner::new(10, "current-name", Some(1000)))
+ );
+ }
+
+ #[test]
+ fn bpf_startup_owner_joins_the_procfs_socket_inode() {
+ let mut owners = StartupSocketOwners::default();
+ owners.insert(123, SocketOwner::new(10, "nordvpnd", Some(0)));
+ let (inode_map, _shared) = owners.into_parts();
+ let mut process_map = ConnectionProcessMap::new();
+ let mut sockets = Vec::new();
+ let table = concat!(
+ " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
+ " 0: 0100007F:1F90 08080808:01BB 01 00000000:00000000 00:00000000 00000000 0 0 123\n",
+ );
+
+ LinuxProcessLookup::parse_and_map_content(
+ table,
+ Protocol::Tcp,
+ &inode_map,
+ &mut process_map,
+ &mut sockets,
+ );
+
+ let socket = sockets.first().expect("socket row must parse");
+ assert_eq!(socket.native_id, Some(123));
+ assert_eq!(
+ socket.owner,
+ Some(SocketOwner::new(10, "nordvpnd", Some(0)))
+ );
+ assert_eq!(
+ process_map.get(&key("127.0.0.1:8080", "8.8.8.8:443")),
+ Some(&(10, "nordvpnd".to_string()))
+ );
+ }
+
/// Attribute to this very test process so the /proc reads have a live
/// target with known credentials and a known executable.
fn own_pid() -> u32 {
@@ -586,6 +898,276 @@ mod tests {
assert_eq!(parse_proc_tcp_state("FF"), HostTcpState::Unknown);
}
+ fn own_comm() -> String {
+ fs::read_to_string(format!("/proc/{}/comm", own_pid()))
+ .expect("own comm readable")
+ .trim()
+ .to_string()
+ }
+
+ /// An established TCP socket row as the refreshed inventory would list
+ /// it after the uid drop: tuple and inode visible, owner or not.
+ fn host_socket(local: &str, remote: &str, inode: u64) -> HostSocket {
+ let mut socket = HostSocket::new(
+ Protocol::Tcp,
+ local.parse().unwrap(),
+ HostSocketState::Tcp(HostTcpState::Established),
+ );
+ socket.remote_addr = Some(remote.parse().unwrap());
+ socket.native_id = Some(inode);
+ socket
+ }
+
+ fn snapshot_lookup(
+ owner: (&str, &str, u32, String, u64),
+ inventory: Vec,
+ ) -> LinuxProcessLookup {
+ let (local, remote, pid, name, inode) = owner;
+ let mut startup = HashMap::new();
+ startup.insert(
+ key(local, remote),
+ SnapshotOwner {
+ pid,
+ name,
+ inode,
+ start_ticks: process_start_ticks(pid),
+ },
+ );
+ LinuxProcessLookup {
+ startup_snapshot: startup,
+ cache: RwLock::new(ConnectionProcessMap::new()),
+ #[cfg(feature = "ebpf")]
+ pid_names: RwLock::new(HashMap::new()),
+ lineages: RwLock::new(HashMap::new()),
+ socket_snapshot: RwLock::new(SocketSnapshot::new(inventory)),
+ }
+ }
+
+ fn owned_socket(local: &str, remote: &str, inode: u64, pid: u32, name: &str) -> HostSocket {
+ let mut socket = host_socket(local, remote, inode);
+ socket.owner = Some(SocketOwner::new(pid, name, None));
+ socket
+ }
+
+ #[test]
+ fn startup_snapshot_pairs_owner_and_inode_from_the_same_row() {
+ // Two owned sockets: each snapshot entry must carry its own row's
+ // inode and owner, never a mix.
+ let sockets = SocketSnapshot::new(vec![
+ owned_socket("192.168.1.10:44444", "203.0.113.5:22", 777, 41, "sshd"),
+ owned_socket("192.168.1.10:55555", "203.0.113.6:443", 888, 42, "nginx"),
+ ]);
+ let snapshot = build_startup_snapshot(&sockets, &HashSet::new());
+
+ let a = &snapshot[&key("192.168.1.10:44444", "203.0.113.5:22")];
+ assert_eq!((a.pid, a.name.as_str(), a.inode), (41, "sshd", 777));
+ let b = &snapshot[&key("192.168.1.10:55555", "203.0.113.6:443")];
+ assert_eq!((b.pid, b.name.as_str(), b.inode), (42, "nginx", 888));
+ }
+
+ #[test]
+ fn startup_snapshot_drops_a_tuple_occupied_by_two_sockets() {
+ // SO_REUSEPORT plus connected UDP permits exact duplicate tuples.
+ // Even when only one row resolved an owner (the other hidden by
+ // permissions or a scan race), the tuple is ambiguous: the owner
+ // must not be paired with either inode.
+ let owned = owned_socket("192.168.1.10:5353", "203.0.113.5:5353", 777, 41, "resolver");
+ let ownerless = host_socket("192.168.1.10:5353", "203.0.113.5:5353", 778);
+ let sockets = SocketSnapshot::new(vec![owned, ownerless]);
+ let snapshot = build_startup_snapshot(&sockets, &HashSet::new());
+
+ assert!(snapshot.is_empty());
+ }
+
+ #[test]
+ fn startup_snapshot_skips_a_socket_held_by_several_processes() {
+ // The live table keeps the master as a best-effort owner, but the
+ // long-lived fallback must not freeze that pick: the master can exit
+ // while a worker keeps the socket open.
+ let sockets = SocketSnapshot::new(vec![owned_socket(
+ "192.168.1.10:44444",
+ "203.0.113.5:22",
+ 777,
+ 1000,
+ "nginx",
+ )]);
+ let shared = HashSet::from([777]);
+
+ assert!(build_startup_snapshot(&sockets, &shared).is_empty());
+ }
+
+ #[test]
+ fn startup_snapshot_skips_ownerless_and_inodeless_rows() {
+ let ownerless = host_socket("192.168.1.10:44444", "203.0.113.5:22", 777);
+ let mut inodeless = owned_socket("192.168.1.10:55555", "203.0.113.6:443", 0, 41, "sshd");
+ inodeless.native_id = None;
+ let sockets = SocketSnapshot::new(vec![ownerless, inodeless]);
+
+ assert!(build_startup_snapshot(&sockets, &HashSet::new()).is_empty());
+ }
+
+ #[test]
+ fn startup_snapshot_attributes_when_the_rescanned_table_cannot() {
+ // A connection whose owner is present in the startup owner scan but
+ // invisible to every post-uid-drop rescan: same socket
+ // (same inode) still on the tuple, owner still alive. The snapshot
+ // records this very test process so /proc validation has a real
+ // target.
+ let lookup = snapshot_lookup(
+ (
+ "192.168.1.10:44444",
+ "203.0.113.5:22",
+ own_pid(),
+ own_comm(),
+ 777,
+ ),
+ vec![host_socket("192.168.1.10:44444", "203.0.113.5:22", 777)],
+ );
+
+ let conn = connection("192.168.1.10:44444", "203.0.113.5:22");
+ let (got_pid, got_name, quality) =
+ lookup.lookup_match(&conn).expect("snapshot should match");
+ assert_eq!(got_pid, own_pid());
+ assert_eq!(got_name, own_comm());
+ assert_eq!(quality, MatchQuality::StartupSnapshot);
+ }
+
+ #[test]
+ fn startup_snapshot_rejects_a_reused_tuple() {
+ // The startup socket closed and another (owner-invisible) socket
+ // reuses the exact tuple: the inventory shows a different inode,
+ // so the stale owner must not attribute, even though it still runs.
+ let lookup = snapshot_lookup(
+ (
+ "192.168.1.10:44444",
+ "203.0.113.5:22",
+ own_pid(),
+ own_comm(),
+ 777,
+ ),
+ vec![host_socket("192.168.1.10:44444", "203.0.113.5:22", 778)],
+ );
+
+ let conn = connection("192.168.1.10:44444", "203.0.113.5:22");
+ assert!(lookup.lookup_match(&conn).is_none());
+ }
+
+ #[test]
+ fn startup_snapshot_rejects_a_closed_socket() {
+ // The tuple is gone from the current inventory entirely.
+ let lookup = snapshot_lookup(
+ (
+ "192.168.1.10:44444",
+ "203.0.113.5:22",
+ own_pid(),
+ own_comm(),
+ 777,
+ ),
+ Vec::new(),
+ );
+
+ let conn = connection("192.168.1.10:44444", "203.0.113.5:22");
+ assert!(lookup.lookup_match(&conn).is_none());
+ }
+
+ #[test]
+ fn startup_snapshot_rejects_an_owner_that_no_longer_matches() {
+ // Socket unchanged, but the recorded owner has exited (or its PID
+ // was reused): a PID above the kernel's pid_max cannot exist.
+ let lookup = snapshot_lookup(
+ (
+ "192.168.1.10:44444",
+ "203.0.113.5:22",
+ u32::MAX,
+ "sshd".to_string(),
+ 777,
+ ),
+ vec![host_socket("192.168.1.10:44444", "203.0.113.5:22", 777)],
+ );
+
+ let conn = connection("192.168.1.10:44444", "203.0.113.5:22");
+ assert!(lookup.lookup_match(&conn).is_none());
+ }
+
+ #[test]
+ fn startup_snapshot_rejects_a_reused_pid_that_took_the_owner_name() {
+ // Socket unchanged and a live process answers to the recorded name,
+ // but it started later: any process can rename itself with
+ // prctl(PR_SET_NAME), so only the start time settles PID reuse.
+ let mut startup = HashMap::new();
+ startup.insert(
+ key("192.168.1.10:44444", "203.0.113.5:22"),
+ SnapshotOwner {
+ pid: own_pid(),
+ name: own_comm(),
+ inode: 777,
+ start_ticks: process_start_ticks(own_pid()).map(|ticks| ticks + 1),
+ },
+ );
+ let lookup = LinuxProcessLookup {
+ startup_snapshot: startup,
+ cache: RwLock::new(ConnectionProcessMap::new()),
+ #[cfg(feature = "ebpf")]
+ pid_names: RwLock::new(HashMap::new()),
+ lineages: RwLock::new(HashMap::new()),
+ socket_snapshot: RwLock::new(SocketSnapshot::new(vec![host_socket(
+ "192.168.1.10:44444",
+ "203.0.113.5:22",
+ 777,
+ )])),
+ };
+
+ let conn = connection("192.168.1.10:44444", "203.0.113.5:22");
+ assert!(lookup.lookup_match(&conn).is_none());
+ }
+
+ #[test]
+ fn startup_snapshot_never_matches_relaxed_shapes() {
+ // A stale wildcard listener entry in the snapshot must not claim
+ // new inbound connections; only exact 4-tuple hits are trusted.
+ let lookup = snapshot_lookup(
+ ("0.0.0.0:80", "0.0.0.0:0", own_pid(), own_comm(), 777),
+ vec![host_socket("192.168.1.10:80", "203.0.113.5:50000", 900)],
+ );
+
+ let conn = connection("192.168.1.10:80", "203.0.113.5:50000");
+ assert!(lookup.lookup_match(&conn).is_none());
+ }
+
+ #[test]
+ fn live_table_wins_over_the_startup_snapshot() {
+ // A 4-tuple reused after startup: the rescan sees the new owner and
+ // must shadow the stale snapshot entry.
+ let k = key("192.168.1.10:44444", "203.0.113.5:443");
+ let mut snapshot = HashMap::new();
+ snapshot.insert(
+ k.clone(),
+ SnapshotOwner {
+ pid: 4242,
+ name: "old-owner".to_string(),
+ inode: 777,
+ start_ticks: None,
+ },
+ );
+ let mut live = ConnectionProcessMap::new();
+ live.insert(k, (5555, "curl".to_string()));
+
+ let lookup = LinuxProcessLookup {
+ startup_snapshot: snapshot,
+ cache: RwLock::new(live),
+ #[cfg(feature = "ebpf")]
+ pid_names: RwLock::new(HashMap::new()),
+ lineages: RwLock::new(HashMap::new()),
+ socket_snapshot: RwLock::new(SocketSnapshot::default()),
+ };
+
+ let conn = connection("192.168.1.10:44444", "203.0.113.5:443");
+ let (pid, name, quality) = lookup.lookup_match(&conn).expect("live table should match");
+ assert_eq!(pid, 5555);
+ assert_eq!(name, "curl");
+ assert_eq!(quality, MatchQuality::ProcfsExact);
+ }
+
#[test]
fn truncated_comm_is_recovered_from_the_executable_name() {
assert_eq!(