Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ Agent-facing diagnostic responses also include `ontology_evidence`: schema-versi

Optional TCP evidence is controlled by `RAUTA_TCP_EVIDENCE_MODE`: `unavailable` (default), `mock`, or `linux-ebpf`. The Linux eBPF mode is target-gated and reports unavailable unless the process has the required Linux capabilities such as `CAP_BPF`/`CAP_PERFMON` or equivalent privileged execution; it never becomes a routing dependency.

Mock TCP evidence can be made anomalous with deterministic environment inputs:
`RAUTA_TCP_EVIDENCE_MOCK_BACKEND`, `RAUTA_TCP_EVIDENCE_MOCK_RTT_US`,
`RAUTA_TCP_EVIDENCE_MOCK_RETRANSMITS`, `RAUTA_TCP_EVIDENCE_MOCK_RESETS`,
`RAUTA_TCP_EVIDENCE_MOCK_CONNECTION_FAILURES`, and
`RAUTA_TCP_EVIDENCE_MOCK_CONGESTION_EVENTS`.

---

## Gateway API
Expand Down
171 changes: 164 additions & 7 deletions control/src/observability/ebpf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ use agent_api::ontology::{
EntityKind, EntityRef, EvidenceKind, OntologyEvidence, ONTOLOGY_SCHEMA_VERSION,
};

const DEFAULT_MOCK_BACKEND: &str = "127.0.0.1:8080";
const CAP_SYS_ADMIN: u8 = 21;
const CAP_PERFMON: u8 = 38;
const CAP_BPF: u8 = 39;

pub trait TcpHealthSensor: Send + Sync {
fn snapshot(&self) -> TcpHealthEvidenceSnapshot;
}
Expand All @@ -26,11 +31,12 @@ pub struct MockTcpHealthSensor;

impl TcpHealthSensor for MockTcpHealthSensor {
fn snapshot(&self) -> TcpHealthEvidenceSnapshot {
let signal = mock_signal_from_env();
TcpHealthEvidenceSnapshot {
mode: TcpEvidenceMode::Mock,
available: true,
message: "mock TCP health evidence".to_string(),
signals: vec![tcp_signal("127.0.0.1:8080", Some(900), 0, 0, 0, 0)],
message: "mock TCP health evidence from deterministic environment inputs".to_string(),
signals: vec![signal],
}
}
}
Expand All @@ -41,10 +47,21 @@ pub struct LinuxEbpfTcpHealthSensor;
#[cfg(target_os = "linux")]
impl TcpHealthSensor for LinuxEbpfTcpHealthSensor {
fn snapshot(&self) -> TcpHealthEvidenceSnapshot {
let capability_state = linux_capability_state();
let message = if capability_state.has_probe_capability {
"Linux eBPF probe capability detected, but loader is not attached in this build"
.to_string()
} else {
format!(
"Linux eBPF probe requires CAP_BPF/CAP_PERFMON or CAP_SYS_ADMIN; missing {}",
capability_state.missing.join("/")
)
};

TcpHealthEvidenceSnapshot {
mode: TcpEvidenceMode::LinuxEbpf,
available: false,
message: "Linux eBPF probe requires CAP_BPF/CAP_PERFMON or privileged execution; loader is not attached in this build".to_string(),
message,
signals: vec![],
}
}
Expand All @@ -66,16 +83,90 @@ impl TcpHealthSensor for LinuxEbpfTcpHealthSensor {
}

pub fn sensor_from_env() -> Box<dyn TcpHealthSensor> {
match std::env::var("RAUTA_TCP_EVIDENCE_MODE")
.unwrap_or_else(|_| "unavailable".to_string())
.as_str()
{
match normalized_mode(std::env::var("RAUTA_TCP_EVIDENCE_MODE").as_deref().ok()) {
"mock" => Box::new(MockTcpHealthSensor),
"linux-ebpf" => Box::new(LinuxEbpfTcpHealthSensor),
_ => Box::new(UnavailableTcpHealthSensor),
}
}

fn normalized_mode(value: Option<&str>) -> &'static str {
match value
.unwrap_or("unavailable")
.trim()
.to_ascii_lowercase()
.as_str()
{
"mock" => "mock",
"linux-ebpf" | "linux_ebpf" | "ebpf" => "linux-ebpf",
_ => "unavailable",
}
}

fn mock_signal_from_env() -> TcpHealthSignal {
tcp_signal(
&std::env::var("RAUTA_TCP_EVIDENCE_MOCK_BACKEND")
.unwrap_or_else(|_| DEFAULT_MOCK_BACKEND.to_string()),
env_u64("RAUTA_TCP_EVIDENCE_MOCK_RTT_US").or(Some(900)),
env_u64("RAUTA_TCP_EVIDENCE_MOCK_RETRANSMITS").unwrap_or_default(),
env_u64("RAUTA_TCP_EVIDENCE_MOCK_RESETS").unwrap_or_default(),
env_u64("RAUTA_TCP_EVIDENCE_MOCK_CONNECTION_FAILURES").unwrap_or_default(),
env_u64("RAUTA_TCP_EVIDENCE_MOCK_CONGESTION_EVENTS").unwrap_or_default(),
)
}

fn env_u64(key: &str) -> Option<u64> {
std::env::var(key).ok()?.parse().ok()
}

#[cfg(target_os = "linux")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct LinuxCapabilityState {
has_probe_capability: bool,
missing: Vec<&'static str>,
}

#[cfg(target_os = "linux")]
fn linux_capability_state() -> LinuxCapabilityState {
linux_capability_state_from_status(
&std::fs::read_to_string("/proc/self/status").unwrap_or_default(),
)
}

#[cfg(target_os = "linux")]
fn linux_capability_state_from_status(status: &str) -> LinuxCapabilityState {
let effective = status
.lines()
.find_map(|line| line.strip_prefix("CapEff:"))
.and_then(|value| u64::from_str_radix(value.trim(), 16).ok())
.unwrap_or_default();
let has_sys_admin = has_capability(effective, CAP_SYS_ADMIN);
let has_bpf_pair = has_capability(effective, CAP_BPF) && has_capability(effective, CAP_PERFMON);
let has_probe_capability = has_sys_admin || has_bpf_pair;
let mut missing = Vec::new();
if !has_probe_capability {
if !has_capability(effective, CAP_BPF) {
missing.push("CAP_BPF");
}
if !has_capability(effective, CAP_PERFMON) {
missing.push("CAP_PERFMON");
}
if !has_sys_admin {
missing.push("CAP_SYS_ADMIN");
}
}

LinuxCapabilityState {
has_probe_capability,
missing,
}
}

#[cfg(target_os = "linux")]
fn has_capability(effective: u64, capability: u8) -> bool {
effective & (1u64 << capability) != 0
}

fn tcp_signal(
backend: &str,
rtt_us: Option<u64>,
Expand Down Expand Up @@ -110,3 +201,69 @@ fn tcp_signal(
ontology_evidence: vec![evidence],
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn normalized_mode_accepts_expected_aliases() {
assert_eq!(normalized_mode(None), "unavailable");
assert_eq!(normalized_mode(Some("mock")), "mock");
assert_eq!(normalized_mode(Some(" MOCK ")), "mock");
assert_eq!(normalized_mode(Some("linux-ebpf")), "linux-ebpf");
assert_eq!(normalized_mode(Some("linux_ebpf")), "linux-ebpf");
assert_eq!(normalized_mode(Some("ebpf")), "linux-ebpf");
assert_eq!(normalized_mode(Some("unexpected")), "unavailable");
}

#[test]
fn tcp_signal_attaches_ontology_attributes() {
let signal = tcp_signal("10.0.0.1:8080", Some(251_000), 2, 1, 0, 3);
assert_eq!(signal.backend, "10.0.0.1:8080");
assert_eq!(signal.rtt_us, Some(251_000));
assert_eq!(signal.retransmits, 2);
assert_eq!(signal.resets, 1);
assert_eq!(signal.congestion_events, 3);

assert_eq!(signal.ontology_evidence.len(), 1);
let evidence = &signal.ontology_evidence[0];
assert_eq!(evidence.schema_version, ONTOLOGY_SCHEMA_VERSION);
assert_eq!(evidence.attributes.get("rtt_us"), Some(&251_000u64.into()));
assert_eq!(evidence.attributes.get("retransmits"), Some(&2u64.into()));
assert_eq!(evidence.attributes.get("resets"), Some(&1u64.into()));
assert_eq!(
evidence.attributes.get("congestion_events"),
Some(&3u64.into())
);
}

#[cfg(target_os = "linux")]
#[test]
fn linux_capability_state_accepts_sys_admin() {
let cap_eff = 1u64 << CAP_SYS_ADMIN;
let state = linux_capability_state_from_status(&format!("CapEff:\t{cap_eff:016x}\n"));
assert!(state.has_probe_capability);
assert!(state.missing.is_empty());
}

#[cfg(target_os = "linux")]
#[test]
fn linux_capability_state_accepts_bpf_and_perfmon_pair() {
let cap_eff = (1u64 << CAP_BPF) | (1u64 << CAP_PERFMON);
let state = linux_capability_state_from_status(&format!("CapEff:\t{cap_eff:016x}\n"));
assert!(state.has_probe_capability);
assert!(state.missing.is_empty());
}

#[cfg(target_os = "linux")]
#[test]
fn linux_capability_state_reports_missing_probe_caps() {
let state = linux_capability_state_from_status("CapEff:\t0000000000000000\n");
assert!(!state.has_probe_capability);
assert_eq!(
state.missing,
vec!["CAP_BPF", "CAP_PERFMON", "CAP_SYS_ADMIN"]
);
}
}