Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ set(AEGIS_SOURCES
src/kernel_features.cpp
src/network_ops.cpp
src/otlp_exporter.cpp
src/metrics_server.cpp
src/socket_api.cpp
src/ttl_registry.cpp
src/policy.cpp
Expand Down Expand Up @@ -563,6 +564,7 @@ set(AEGIS_LIB_SOURCES
src/kernel_features.cpp
src/network_ops.cpp
src/otlp_exporter.cpp
src/metrics_server.cpp
src/socket_api.cpp
src/ttl_registry.cpp
src/policy.cpp
Expand Down Expand Up @@ -697,6 +699,7 @@ if(BUILD_TESTING)
tests/test_net_block_event_dedup.cpp
tests/test_event_decode.cpp
tests/test_posture_gate.cpp
tests/test_metrics_server.cpp
tests/test_socket_api.cpp
tests/test_ttl_registry.cpp
)
Expand Down
18 changes: 18 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — Prometheus metrics endpoint
- **Opt-in HTTP `/metrics` endpoint** (`AEGIS_METRICS_ADDR=<host:port>`,
`src/metrics_server.{hpp,cpp}`, `src/daemon.cpp` wiring, `docs/METRICS.md`) —
serves the agent's Prometheus exposition over HTTP so Prometheus /
kube-prometheus can scrape it directly, reusing the daemon's already-loaded BPF
state (no per-scrape reload). Off by default; routes `GET /metrics` and
`GET /healthz`. Binds loopback by default; bind `:9635` to expose and restrict
with a firewall / NetworkPolicy (no auth, standard for a scrape target). The
Prometheus builder was refactored out of the `metrics` CLI command into a shared
`build_metrics_report(BpfState&, bool)` so the CLI, the textfile collector, and
the HTTP endpoint all emit identical output. A new `aegisbpf_deny_ttl_entries`
gauge exposes the count of control-API denies awaiting TTL expiry.
- **node_exporter textfile-collector units** (`packaging/systemd/aegisbpf-metrics.{service,timer}`)
— the no-open-port alternative: a 30 s timer writes the exposition atomically to
`${AEGIS_METRICS_TEXTFILE}` for node_exporter to serve.
- New GTest suite `tests/test_metrics_server.cpp` (bind-addr parsing, real
loopback round-trip for `/metrics` `/healthz` 404, no-callback 503, non-GET 405).

## [0.10.0] - 2026-08-11

Ecosystem integration and programmatic enforcement: AegisBPF now plugs into the
Expand Down
69 changes: 69 additions & 0 deletions docs/METRICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Metrics

AegisBPF exposes its enforcement state as Prometheus metrics. There are two ways
to get them — pick one:

## 1. Built-in HTTP endpoint (opt-in)

Set `AEGIS_METRICS_ADDR` on the agent and it serves the exposition over HTTP:

```bash
AEGIS_METRICS_ADDR=127.0.0.1:9635 aegisbpf run --enforce
curl -s http://127.0.0.1:9635/metrics
```

- `GET /metrics` → Prometheus exposition (`text/plain; version=0.0.4`).
- `GET /healthz` → `ok` (liveness).
- Off unless `AEGIS_METRICS_ADDR` is set. The daemon reuses its already-loaded
BPF state, so a scrape does not reload anything.

**Security.** The endpoint has no authentication (standard for a Prometheus
scrape target). Bind **loopback** (`127.0.0.1:9635`) unless a scraper needs the
node/pod IP; to expose it, bind `:9635` (all interfaces) and restrict access with
a firewall or Kubernetes `NetworkPolicy`. In Kubernetes, add a container port and
a `ServiceMonitor`/`PodMonitor` pointing at `/metrics`.

## 2. node_exporter textfile collector (no open port)

If you'd rather not open a port, write the exposition to a file that
node_exporter serves. Ships as a systemd timer:

```bash
systemctl enable --now aegisbpf-metrics.timer # runs `aegisbpf metrics` every 30s
```

It writes `${AEGIS_METRICS_TEXTFILE}` (default
`/var/lib/node_exporter/textfile_collector/aegisbpf.prom`) atomically (temp +
rename). Point node_exporter at that directory with
`--collector.textfile.directory`. You can also run it by hand:

```bash
aegisbpf metrics --out /var/lib/node_exporter/textfile_collector/aegisbpf.prom
aegisbpf metrics # or just print to stdout
aegisbpf metrics --detailed # high-cardinality per-path / per-inode / per-ip series
```

## What's exposed

Low-cardinality by default (35+ families), read straight from the pinned BPF
maps and agent state. Highlights:

| Metric | Type | Meaning |
|---|---|---|
| `aegisbpf_blocks_total` | counter | Total blocked file operations |
| `aegisbpf_ringbuf_drops_total` | counter | Dropped ring-buffer events |
| `aegisbpf_net_blocks_total{type=…}` | counter | Blocked network ops by direction |
| `aegisbpf_deny_inode_entries` / `aegisbpf_deny_path_entries` | gauge | Active file-deny map sizes |
| `aegisbpf_deny_ttl_entries` | gauge | Control-API denies with a pending TTL (auto-expiry) |
| `aegisbpf_map_utilization{map=…}` | gauge | Per-map fill ratio (capacity pressure) |
| `aegisbpf_runtime_state{state=…}` | gauge | Posture: ENFORCE / ENFORCE_SIGNAL / AUDIT_FALLBACK / DEGRADED |
| `aegisbpf_hook_latency_max_ns` | gauge | Worst-case LSM hook latency |
| `aegisbpf_backpressure_*` | counter | Dual-path telemetry submit/drop counters |
| `aegisbpf_enforce_capable` | gauge | Whether the kernel/config can enforce |

`--detailed` (CLI) adds high-cardinality per-path / per-inode / per-ip / per-port
series — use sparingly.

> **Note.** Daemon in-process health counters (e.g. `pin_heal_*`) are surfaced via
> structured logs today; exposing them as metrics is a planned follow-up (the
> counters are made scrape-safe first).
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ A categorized map of the `docs/` tree. For a high-level overview start with the
- [BTF Fallback](BTF_FALLBACK.md)

## Operations & Runbooks
- [Monitoring & Alerting Guide](MONITORING_GUIDE.md) · [Metrics Operations](METRICS_OPERATIONS.md)
- [Monitoring & Alerting Guide](MONITORING_GUIDE.md) · [Metrics Operations](METRICS_OPERATIONS.md) · [Metrics endpoint (Prometheus)](METRICS.md)
- [Troubleshooting Guide](TROUBLESHOOTING.md) · [Error Handling Guidelines](ERROR_HANDLING.md)
- [Emergency Recovery Runbook](RUNBOOK_RECOVERY.md) · [Incident Response Runbook](INCIDENT_RESPONSE.md)
- [Staging Canary Runbook](CANARY_RUNBOOK.md) · [Release Drill Runbook](RELEASE_DRILL.md)
Expand Down
1 change: 1 addition & 0 deletions docs/man/aegisbpf.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ Exported metrics:
- `aegisbpf_perf_slo_failed_rows`
- `aegisbpf_deny_inode_entries`
- `aegisbpf_deny_path_entries`
- `aegisbpf_deny_ttl_entries`
- `aegisbpf_allow_cgroup_entries`
- `aegisbpf_allow_exec_inode_entries`
- `aegisbpf_map_capacity{map}`
Expand Down
30 changes: 30 additions & 0 deletions packaging/systemd/aegisbpf-metrics.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# AegisBPF metrics — Prometheus node_exporter textfile-collector writer.
#
# Runs `aegisbpf metrics` and writes the exposition to a .prom file that the
# node_exporter textfile collector picks up. This is the zero-network-port
# alternative to the agent's built-in HTTP endpoint (AEGIS_METRICS_ADDR): no
# port is opened; node_exporter serves the metrics for you.
#
# Enable the paired timer to run it periodically:
# systemctl enable --now aegisbpf-metrics.timer
#
# Override the output directory via /etc/default/aegisbpf (AEGIS_METRICS_TEXTFILE).
[Unit]
Description=AegisBPF Prometheus metrics (textfile collector writer)
After=aegisbpf.service
ConditionPathExists=/sys/fs/bpf

[Service]
Type=oneshot
EnvironmentFile=-/etc/default/aegisbpf
Environment=AEGIS_METRICS_TEXTFILE=/var/lib/node_exporter/textfile_collector/aegisbpf.prom
# Write to a temp file and rename so node_exporter never reads a partial file.
ExecStart=/bin/sh -c 'd=$(dirname "$AEGIS_METRICS_TEXTFILE"); mkdir -p "$d"; /usr/bin/aegisbpf metrics --out "$AEGIS_METRICS_TEXTFILE.tmp" && mv "$AEGIS_METRICS_TEXTFILE.tmp" "$AEGIS_METRICS_TEXTFILE"'
LimitMEMLOCK=infinity
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelModules=true
ReadWritePaths=/sys/fs/bpf /var/lib/aegisbpf /var/lib/node_exporter
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_RESOURCE CAP_BPF CAP_PERFMON
AmbientCapabilities=CAP_SYS_ADMIN CAP_SYS_RESOURCE CAP_BPF CAP_PERFMON
17 changes: 17 additions & 0 deletions packaging/systemd/aegisbpf-metrics.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Periodically refresh the AegisBPF Prometheus textfile-collector output.
#
# systemctl enable --now aegisbpf-metrics.timer
#
# Pairs with aegisbpf-metrics.service. 30s cadence matches a typical Prometheus
# scrape interval; adjust OnUnitActiveSec to taste.
[Unit]
Description=Refresh AegisBPF Prometheus metrics textfile every 30s

[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=5s
Unit=aegisbpf-metrics.service

[Install]
WantedBy=timers.target
54 changes: 35 additions & 19 deletions src/commands_metrics.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "logging.hpp"
#include "network_ops.hpp"
#include "tracing.hpp"
#include "ttl_registry.hpp"
#include "types.hpp"
#include "utils.hpp"

Expand Down Expand Up @@ -321,24 +322,14 @@ int cmd_stats(bool detailed)
return 0;
}

int cmd_metrics(const std::string& out_path, bool detailed)
Result<std::string> build_metrics_report(BpfState& state, bool detailed)
{
const std::string trace_id = make_span_id("trace-metrics");
ScopedSpan span("cli.metrics", trace_id);

BpfState state;
auto load_result = load_bpf(true, false, state);
if (!load_result) {
logger().log(SLOG_ERROR("Failed to load BPF object").field("error", load_result.error().to_string()));
return fail_span(span, load_result.error().to_string());
}

std::ostringstream oss;

auto stats_result = read_block_stats_map(state.block_stats);
if (!stats_result) {
logger().log(SLOG_ERROR("Failed to read block stats").field("error", stats_result.error().to_string()));
return fail_span(span, stats_result.error().to_string());
return stats_result.error();
}
const auto& stats = *stats_result;
append_metric_header(oss, "aegisbpf_blocks_total", "counter", "Total number of blocked operations");
Expand All @@ -353,7 +344,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
if (!cgroup_stats_result) {
logger().log(SLOG_ERROR("Failed to read cgroup block stats")
.field("error", cgroup_stats_result.error().to_string()));
return fail_span(span, cgroup_stats_result.error().to_string());
return cgroup_stats_result.error();
}
auto cgroup_stats = *cgroup_stats_result;
std::sort(cgroup_stats.begin(), cgroup_stats.end(),
Expand All @@ -372,7 +363,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
if (!inode_stats_result) {
logger().log(
SLOG_ERROR("Failed to read inode block stats").field("error", inode_stats_result.error().to_string()));
return fail_span(span, inode_stats_result.error().to_string());
return inode_stats_result.error();
}
auto inode_stats = *inode_stats_result;
std::sort(inode_stats.begin(), inode_stats.end(), [](const auto& a, const auto& b) {
Expand All @@ -390,7 +381,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
if (!path_stats_result) {
logger().log(
SLOG_ERROR("Failed to read path block stats").field("error", path_stats_result.error().to_string()));
return fail_span(span, path_stats_result.error().to_string());
return path_stats_result.error();
}
auto path_stats = *path_stats_result;
std::sort(path_stats.begin(), path_stats.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
Expand All @@ -405,7 +396,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
if (!net_stats_result) {
logger().log(
SLOG_ERROR("Failed to read network block stats").field("error", net_stats_result.error().to_string()));
return fail_span(span, net_stats_result.error().to_string());
return net_stats_result.error();
}

const auto& net_stats = *net_stats_result;
Expand All @@ -427,7 +418,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
if (!net_ip_stats_result) {
logger().log(SLOG_ERROR("Failed to read network IP stats")
.field("error", net_ip_stats_result.error().to_string()));
return fail_span(span, net_ip_stats_result.error().to_string());
return net_ip_stats_result.error();
}
auto net_ip_stats = *net_ip_stats_result;
std::sort(net_ip_stats.begin(), net_ip_stats.end(),
Expand All @@ -444,7 +435,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
if (!net_port_stats_result) {
logger().log(SLOG_ERROR("Failed to read network port stats")
.field("error", net_port_stats_result.error().to_string()));
return fail_span(span, net_port_stats_result.error().to_string());
return net_port_stats_result.error();
}
auto net_port_stats = *net_port_stats_result;
std::sort(net_port_stats.begin(), net_port_stats.end(),
Expand Down Expand Up @@ -685,7 +676,32 @@ int cmd_metrics(const std::string& out_path, bool detailed)
(perf_slo_sample.summary_present && perf_slo_sample.parse_ok) ? perf_slo_sample.failed_rows
: 0);

std::string metrics = oss.str();
// Timed-deny registry size (control-API TTL denies awaiting expiry). File-based
// (deny_ttl.db), so it is correct from both the CLI and the daemon endpoint.
append_metric_header(oss, "aegisbpf_deny_ttl_entries", "gauge",
"Number of control-API denies with a pending TTL (auto-expiry)");
append_metric_sample(oss, "aegisbpf_deny_ttl_entries", static_cast<uint64_t>(read_ttl_db(kTtlDbPath).size()));

return oss.str();
}

int cmd_metrics(const std::string& out_path, bool detailed)
{
const std::string trace_id = make_span_id("trace-metrics");
ScopedSpan span("cli.metrics", trace_id);

BpfState state;
auto load_result = load_bpf(true, false, state);
if (!load_result) {
logger().log(SLOG_ERROR("Failed to load BPF object").field("error", load_result.error().to_string()));
return fail_span(span, load_result.error().to_string());
}

auto report = build_metrics_report(state, detailed);
if (!report) {
return fail_span(span, report.error().to_string());
}
const std::string& metrics = *report;

if (out_path.empty() || out_path == "-") {
std::cout << metrics;
Expand Down
7 changes: 7 additions & 0 deletions src/commands_metrics.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@

#include <string>

#include "bpf_ops.hpp"
#include "result.hpp"
#include "types.hpp"

namespace aegis {

int cmd_stats(bool detailed = false);
int cmd_metrics(const std::string& out_path, bool detailed = false);

// Build the full Prometheus exposition text from a loaded BpfState. Shared by the
// `metrics` CLI command and the daemon's optional HTTP /metrics endpoint (which
// reuses its already-loaded state instead of reloading per scrape).
Result<std::string> build_metrics_report(BpfState& state, bool detailed);

std::string build_block_metrics_output(const BlockStats& stats);
std::string build_net_metrics_output(const NetBlockStats& stats);

Expand Down
27 changes: 27 additions & 0 deletions src/daemon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
#include "bpf_ops.hpp"
#include "capabilities.hpp"
#include "commands_block_allow.hpp"
#include "commands_metrics.hpp"
#include "commands_network.hpp"
#include "daemon_policy_gate.hpp"
#include "daemon_posture.hpp"
Expand All @@ -40,6 +41,7 @@
#include "landlock.hpp"
#include "logging.hpp"
#include "map_monitor.hpp"
#include "metrics_server.hpp"
#include "posture_gate.hpp"
#include "proc_scan.hpp"
#include "seccomp.hpp"
Expand Down Expand Up @@ -1111,6 +1113,31 @@ int daemon_run(bool audit_only, bool enable_seccomp, bool enable_landlock, bool
}
}

// Optional Prometheus metrics endpoint (opt-in via AEGIS_METRICS_ADDR=<host:port>).
// Off by default. Serves the same exposition as `aegisbpf metrics`, reusing the
// daemon's already-loaded BPF state (no per-scrape reload). Reads are limited to
// pinned maps + files, so they run concurrently with the poll loop safely.
std::unique_ptr<aegis::MetricsServer> metrics_server;
if (const char* metrics_addr = std::getenv("AEGIS_METRICS_ADDR");
metrics_addr != nullptr && metrics_addr[0] != '\0') {
aegis::MetricsServer::Config metrics_cfg;
metrics_cfg.bind_addr = metrics_addr;
metrics_server = std::make_unique<aegis::MetricsServer>(metrics_cfg);
metrics_server->set_metrics_callback([&state]() -> std::string {
auto report = build_metrics_report(state, false);
if (!report) {
return "# metrics unavailable: " + report.error().to_string() + "\n";
}
return *report;
});
if (metrics_server->start()) {
logger().log(SLOG_INFO("Prometheus metrics endpoint enabled").field("addr", metrics_addr));
} else {
logger().log(SLOG_ERROR("Failed to start metrics endpoint").field("addr", metrics_addr));
metrics_server.reset();
}
}

ScopedSpan event_loop_span("daemon.event_loop", trace_id, root_span.span_id());
while (!exit_requested()) {
err = ring_buffer__poll(rb.get(), 250);
Expand Down
Loading
Loading