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
41 changes: 24 additions & 17 deletions docs/agents/fw-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ section to disable the API entirely.
| GET | `/tracked` | live per-destination RX/TX rates (paginated + filtered) |
| GET | `/ports` | learned open ports per protected IP (paginated + filtered) |
| GET | `/sources` | **the** unified source list: every rate-tracked source (3-state + live pps) plus manual blocks, paginated + filtered |
| GET | `/blocks` | legacy: the enforced block trie only (manual + auto dropping/cooling, CIDR-aggregated), paginated |
| GET | `/blocks` | legacy: currently-blocked sources only (manual CIDRs + kernel-blocked /32s|/128s), paginated |
| POST | `/blocks` | add a permanent manual source block `{cidr}` (updates the ruleset) |
| DELETE | `/blocks?cidr=<cidr>` | remove a manual source block |
| GET | `/upgrade` | cached self-upgrade status: `current`, `latest`, `available`, `deb_url` |
Expand All @@ -66,31 +66,38 @@ neither can hide:

- `pps`/`syn_pps`/`bps` (+ `net_*` prefix aggregates) — *destination* entry
thresholds: “is this dest under attack?”
- `src_rate_pps` (+ `src_exit_pct`, `src_cooldown_secs`) — the *per-source*
auto-block threshold: once a dest is mitigating, any single source at/over
this rate is blocked. Necessarily much lower than the dest threshold, but
keep it well above shared-infrastructure rates (CDN/reverse-proxy edges,
CGNAT) — default 10 000 pps. Mirrors `escalation.src-rate-pps` in the config
file; the API value wins after a PUT.
- `src_rate_pps` (+ `src_cooldown_secs`) — the *per-source* auto-block
threshold, enforced by the in-kernel rate machine over an exact 1s window:
once a dest is mitigating, any single source over this rate is blocked for
the cooldown. Necessarily much lower than the dest threshold, but keep it
well above shared-infrastructure rates (CDN/reverse-proxy edges, CGNAT) —
default 10 000 pps. Mirrors `escalation.src-rate-pps` in the config file;
the API value wins after a PUT (userspace re-writes the kernel config map).

Omitting the `src_*` fields in a PUT (older clients) falls back to their
defaults rather than zeroing them.

### Source list (`/sources`)

While a destination is under mitigation the datapath counts every source IP and
userspace runs a per-source rate state machine. `GET /sources` is the **single**
source list the UI shows: it surfaces that full set in every state, **plus**
operator-pushed manual blocks. There is no separate “blocks list” — an auto block
is simply an entry whose `state` is `dropping`/`cooling`.
While a destination is under mitigation, the **XDP datapath itself** runs a
fixed-window rate machine per source IP: it counts each source over a 1s
window and blocks over-rate sources in-line (`blocked_until` in its state
map), with drops engaging once the destination escalates to `SOURCE_BLOCK`.
Userspace is not in the decision path — it snapshots the kernel state map
(batched) purely for this view. `GET /sources` is the **single** source list
the UI shows, plus operator-pushed manual blocks. An auto block is simply an
entry whose `state` is `dropping`.

Each item is `{ ip, pps, state, manual, age_secs }` where `state` is one of:

- `normal` — under the per-source limit (`src-rate-pps`); tracked but **not** blocked.
- `dropping` — at/over the limit; installed in the CIDR block trie (packets dropped).
- `cooling` — still blocked, but the rate fell below the exit threshold
(`src-exit-pct` of the limit) and is counting down `src-cooldown-secs` before
release. A new at/over-rate window flips it back to `dropping`.
- `normal` — under the per-source limit (`src-rate-pps`); counted but **not**
blocked.
- `dropping` — tripped the limit; the kernel drops its packets until the
cooldown expires (re-extended every window it stays over-rate).

`pps` is estimated from the current kernel window (count over elapsed); an
idle source's rate decays toward zero naturally. A `normal` source idle for
60s is swept from the kernel state map by the daemon's GC timer.

`manual: true` rows are permanent operator blocks (from `POST /blocks`); they are
dropped before per-source counting so they always report `pps: 0` and are pinned
Expand Down
29 changes: 17 additions & 12 deletions lnvps_fw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ single-IP attacks.
high-efficacy open-port drop does the heavy lifting first; source/CIDR
blocking (highest false-positive risk, useless against spoofed floods) is a
last resort, and is gated so it never fires against spoofed traffic.
4. **Everything is bounded.** Per-source counters are LRU-bounded; the CIDR block
structure is an LPM trie kept small by aggregation. The datapath never tries
to track "every /32".
4. **Everything is bounded.** Per-source rate state is LRU-bounded and lives in
the datapath itself; a spoofed high-cardinality flood churns entries without
ever tripping a per-source limit. The daemon never scans unbounded state on
its hot path (reads are batched and display-only).

---

Expand Down Expand Up @@ -71,8 +72,9 @@ single-IP attacks.
┌── lnvps_fw_service (userspace daemon) ───────────────────────┐
│ • samples per-dest + per-prefix counters, runs detection │
│ state machine, writes protection flags into DEST_STATE │
│ • samples per-source counters, aggregates offenders into │
│ CIDR blocks (spoof-gated), decays them │
│ • arms the in-kernel per-source rate machine (which counts │
│ and blocks over-rate sources in XDP on its own) and │
│ snapshots its state for the API views │
│ • learned-port TTL GC, cookie-secret rotation, verified GC │
│ • loads config (interfaces, thresholds, protected prefixes) │
└──────────────────────────────────────────────────────────────┘
Expand All @@ -94,7 +96,7 @@ single-IP attacks.
|---|---|---|
| `lnvps_ebpf` | `bpfel-unknown-none` | The XDP + TC eBPF programs and maps. Not a default workspace member. |
| `lnvps_fw_common` | host + eBPF (`#![no_std]`) | Types shared across the map boundary (counters, keys, `DestState`), the SYN-cookie function, and constants. `aya::Pod` impls behind the `user` feature. |
| `lnvps_fw_service` | host | The userspace daemon: config, detection state machine, CIDR aggregation, GC, and the control loop that programs the maps. Also exposes a library used by the test harness. |
| `lnvps_fw_service` | host | The userspace daemon: config, per-destination detection state machine, GC, control API, and the control loop that programs the maps (the per-source rate machine runs in the eBPF datapath). Also exposes a library used by the test harness. |

---

Expand Down Expand Up @@ -133,8 +135,9 @@ cheaper layers have not already shed the attack:
- **Spoofed SYN flood to an open TCP port**: `SYN_PROXY` answers with a
SYN-cookie SYN-ACK; spoofed sources can't complete the handshake, so they
never reach the protected host; real clients complete it and are allow-listed.
- **Real botnet hammering open services**: bounded real sources are aggregated
into CIDR blocks (`SOURCE_BLOCK`).
- **Real botnet hammering open services**: the in-kernel per-source rate
machine blocks each over-rate source the moment it crosses the limit
(`SOURCE_BLOCK` gates the drops; spoofed floods never trip it).

---

Expand Down Expand Up @@ -166,7 +169,8 @@ the previous slot keeps in-flight cookies valid across a rotation.
| `V4/V6_SRC_COUNTERS` | LRU per-CPU hash | XDP (under mitigation) | daemon (source control) |
| `V4/V6_DEST_STATE` | LPM trie → `DestState` | daemon | XDP (mode lookup) |
| `OPEN_PORTS_V4/V6` | LRU hash → `LastSeen` | TC egress (learning) | XDP; daemon (TTL GC) |
| `V4/V6_CIDR_SRC` | LPM trie | daemon (escalation) | XDP (`SOURCE_BLOCK`) |
| `V4/V6_SRC_STATE` | LRU hash | XDP (rate machine) | XDP (`SOURCE_BLOCK`) + daemon (display/GC) |
| `SRC_RATE_CFG` | array | daemon (limits) | XDP (rate machine) |
| `VERIFIED_V4/V6` | LRU hash | XDP (`xdp_syn_proxy`/`_v6`) | XDP; daemon (TTL GC) |
| `COOKIE_SECRET` | array[2] | daemon (rotation) | XDP |
| `SYN_PROXY_JUMP` | prog array | daemon (setup) | XDP (`tail_call`) |
Expand Down Expand Up @@ -292,8 +296,9 @@ keys, matching the rest of the LNVPS API config style. Key sections:
- `thresholds` — per-destination entry thresholds + hysteresis/cooldown.
- `network` — aggregate per-prefix thresholds (network scale).
- `learning` — port-learning TTL and GC cadence.
- `escalation` — per-source rate, CIDR aggregation fan-out, spoof gate, and the
SYN-proxy trigger.
- `escalation` — per-source rate limit + cooldown (written into the in-kernel
rate machine), the SOURCE_BLOCK escalation gate, and the SYN-proxy trigger.
(Legacy aggregation/spoof-gate keys are still parsed but ignored.)

> The `protected` prefixes and thresholds will be sourced from the LNVPS API in
> a later increment; the local config is the bootstrap today.
Expand Down Expand Up @@ -321,7 +326,7 @@ Test binaries (`lnvps_fw_service/tests/`):
| `smoke` | program attach, per-dest counters, forwarding, SYN counting |
| `learning` | TC egress learns open TCP/UDP ports; TTL GC |
| `mitigation` | port-filter drops, learned-port pass, detect→cooldown, flag gating |
| `escalation` | per-source rate → aggregated CIDR block + decay |
| `escalation` | per-source rate limit for the in-kernel gate + SOURCE_BLOCK escalation |
| `carpet_bomb` | thin prefix-wide flood flips the whole prefix |
| `syn_proxy` | real client completes the cookie handshake + is verified; spoofed never verify |

Expand Down
13 changes: 7 additions & 6 deletions lnvps_fw/lnvps_ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use network_types::udp::UdpHdr;
mod maps;

use maps::{
OPEN_PORTS_V4, OPEN_PORTS_V6, SYN_PROXY_JUMP, cidr_blocked_v4, cidr_blocked_v6, cookie_secrets,
count_src_v4, count_src_v6, counters_v4, counters_v6, dest_mode_v4, dest_mode_v6,
OPEN_PORTS_V4, OPEN_PORTS_V6, SYN_PROXY_JUMP, cookie_secrets, counters_v4, counters_v6,
dest_mode_v4, dest_mode_v6, src_gate_v4, src_gate_v6,
learn_port_v4, learn_port_v6, manual_blocked_v4, manual_blocked_v6, mark_verified_v4,
mark_verified_v6, port_is_open_v4, port_is_open_v6, protected_v4, protected_v6, scoped,
src_verified_v4, src_verified_v6, tx_counters_v4, tx_counters_v6,
Expand Down Expand Up @@ -257,8 +257,10 @@ fn mitigate_v4(
allow_syn_proxy: bool,
counters: Option<*mut lnvps_fw_common::DestCounters>,
) -> (u32, bool) {
count_src_v4(src);
if flags & DEST_MODE_SOURCE_BLOCK != 0 && cidr_blocked_v4(*src) {
// In-kernel per-source rate machine: counts this packet against the
// source's window and drops while the source is blocked (enforcement is
// gated on the dest's SOURCE_BLOCK escalation, counting is not).
if src_gate_v4(src, flags & DEST_MODE_SOURCE_BLOCK != 0) {
return (XDP_DROP, false);
}
if allow_syn_proxy
Expand Down Expand Up @@ -312,8 +314,7 @@ fn mitigate_v6(
allow_syn_proxy: bool,
counters: Option<*mut lnvps_fw_common::DestCounters>,
) -> (u32, bool) {
count_src_v6(src);
if flags & DEST_MODE_SOURCE_BLOCK != 0 && cidr_blocked_v6(*src) {
if src_gate_v6(src, flags & DEST_MODE_SOURCE_BLOCK != 0) {
return (XDP_DROP, false);
}
if allow_syn_proxy
Expand Down
110 changes: 75 additions & 35 deletions lnvps_fw/lnvps_ebpf/src/maps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use aya_ebpf::maps::lpm_trie::Key;
use aya_ebpf::maps::{Array, LpmTrie, LruHashMap, LruPerCpuHashMap, ProgramArray};
use lnvps_fw_common::{
COOKIE_SECRET_CURRENT, COOKIE_SECRET_PREVIOUS, DEST_MODE_NORMAL, DestCounters, DestState,
LastSeen, PortKeyV4, PortKeyV6,
LastSeen, PortKeyV4, PortKeyV6, SrcRateConfig, SrcState,
};

/// Max number of destination IPs to track (per address family). Sized to a
Expand Down Expand Up @@ -48,17 +48,28 @@ pub static V4_TX_COUNTERS: LruPerCpuHashMap<[u8; 4], DestCounters> =
pub static V6_TX_COUNTERS: LruPerCpuHashMap<[u8; 16], DestCounters> =
LruPerCpuHashMap::with_max_entries(MAX_DST_IPS, 0);

/// Per-source packet counters (IPv4), incremented only while the destination
/// is mitigating. Sampled by userspace to compute per-source rates and drive
/// CIDR escalation. LRU + per-CPU: bounded memory, summed across CPUs.
/// Per-source fixed-window rate state (IPv4), owned by the XDP datapath: the
/// rate calculation AND the blocking decision happen in-kernel, in-line, for
/// packets already in hand — userspace never polls this for detection, it only
/// reads it (batched) for the `/sources` display. LRU: bounded memory; a
/// spoofed high-cardinality flood churns entries without ever tripping the
/// per-source limit (the port-filter layer is the defense there, as before).
/// Shared (not per-CPU): counting races undercount slightly, which is
/// acceptable — the decision uses the same state the packets update.
#[map]
pub static V4_SRC_COUNTERS: LruPerCpuHashMap<[u8; 4], u64> =
LruPerCpuHashMap::with_max_entries(MAX_SRC_IPS, 0);
pub static V4_SRC_STATE: LruHashMap<[u8; 4], SrcState> =
LruHashMap::with_max_entries(MAX_SRC_IPS, 0);

/// Per-source packet counters (IPv6).
/// Per-source rate state (IPv6).
#[map]
pub static V6_SRC_COUNTERS: LruPerCpuHashMap<[u8; 16], u64> =
LruPerCpuHashMap::with_max_entries(MAX_SRC_IPS, 0);
pub static V6_SRC_STATE: LruHashMap<[u8; 16], SrcState> =
LruHashMap::with_max_entries(MAX_SRC_IPS, 0);

/// Per-source rate-machine config (single entry), written by userspace at
/// startup and on live `PUT /limits` edits. `max_per_window == 0` disables
/// per-source blocking.
#[map]
pub static SRC_RATE_CFG: Array<SrcRateConfig> = Array::with_max_entries(1, 0);

/// Mitigation state per destination (IPv4), an LPM trie written by userspace.
/// Using a trie lets userspace mitigate a single IP (a /32 entry) or a whole
Expand All @@ -81,16 +92,6 @@ pub static OPEN_PORTS_V4: LruHashMap<PortKeyV4, LastSeen> =
pub static OPEN_PORTS_V6: LruHashMap<PortKeyV6, LastSeen> =
LruHashMap::with_max_entries(MAX_OPEN_PORTS, 0);

/// Blocked source CIDRs (IPv4), an LPM trie of network-order address bytes.
/// Written by userspace escalation; any source matching a prefix is dropped.
/// Holds both individual /32 offenders and aggregated wider prefixes.
#[map]
pub static V4_CIDR_SRC: LpmTrie<[u8; 4], u8> = LpmTrie::with_max_entries(MAX_CIDR_BLOCKS, 0);

/// Blocked source CIDRs (IPv6).
#[map]
pub static V6_CIDR_SRC: LpmTrie<[u8; 16], u8> = LpmTrie::with_max_entries(MAX_CIDR_BLOCKS, 0);

/// Protected destination prefixes (IPv4). When scoping is enabled, only traffic
/// to a covered destination is counted/mitigated; everything else is passed
/// untouched (so a forwarding router never touches transit traffic).
Expand All @@ -101,9 +102,10 @@ pub static PROTECTED_V4: LpmTrie<[u8; 4], u8> = LpmTrie::with_max_entries(MAX_CI
#[map]
pub static PROTECTED_V6: LpmTrie<[u8; 16], u8> = LpmTrie::with_max_entries(MAX_CIDR_BLOCKS, 0);

/// Operator-pushed manual source-CIDR blocks. Unlike the auto `V*_CIDR_SRC`
/// blocks (which only drop when the destination is escalated to SOURCE_BLOCK),
/// these drop unconditionally for any traffic to a protected destination.
/// Operator-pushed manual source-CIDR blocks. Unlike the automatic per-source
/// rate gate (whose drops engage only when the destination is escalated to
/// SOURCE_BLOCK), these drop unconditionally for any traffic to a protected
/// destination.
#[map]
pub static MANUAL_BLOCK_V4: LpmTrie<[u8; 4], u8> = LpmTrie::with_max_entries(MAX_CIDR_BLOCKS, 0);

Expand Down Expand Up @@ -163,24 +165,64 @@ counters_for!(counters_v6, [u8; 16], V6_DEST_COUNTERS);
counters_for!(tx_counters_v4, [u8; 4], V4_TX_COUNTERS);
counters_for!(tx_counters_v6, [u8; 16], V6_TX_COUNTERS);

/// Generate a per-source packet-count incrementer for one address family
/// (called under mitigation). Pure counting — no policy decision.
macro_rules! count_src_for {
/// Generate the per-source fixed-window rate gate for one address family.
/// Called for every packet to a mitigating destination. Counts the packet
/// against the source's current window and returns `true` (drop) when
/// `enforce` is set and the source is blocked.
///
/// The machine, entirely in-kernel:
/// - window rolled (`now - window_start >= window_ns`): reset the count;
/// - count the packet; crossing `max_per_window` sets/extends
/// `blocked_until = now + cooldown` (a still-flooding source re-extends its
/// block every window, so the block naturally outlives the flood by exactly
/// one cooldown — that is the hysteresis);
/// - blocked sources keep being *counted* (the rate is measured pre-drop) so
/// expiry re-evaluates against live behaviour, not silence.
///
/// Counting is deliberately non-atomic (same as the counters this replaces):
/// cross-CPU races undercount a few packets, which only ever errs toward NOT
/// blocking.
macro_rules! src_gate_for {
($name:ident, $key:ty, $map:ident) => {
#[inline(always)]
pub fn $name(src: &$key) {
if let Some(c) = $map.get_ptr_mut(src) {
unsafe { *c += 1 };
} else {
let one: u64 = 1;
let _ = $map.insert(src, &one, 0);
pub fn $name(src: &$key, enforce: bool) -> bool {
let cfg = match SRC_RATE_CFG.get(0) {
Some(c) => c,
None => return false,
};
if cfg.max_per_window == 0 || cfg.window_ns == 0 {
return false;
}
let now = unsafe { bpf_ktime_get_ns() };
match $map.get_ptr_mut(src) {
Some(st) => {
let st = unsafe { &mut *st };
if now.wrapping_sub(st.window_start_ns) >= cfg.window_ns {
st.window_start_ns = now;
st.count = 0;
}
st.count += 1;
if st.count > cfg.max_per_window {
st.blocked_until_ns = now + cfg.cooldown_ns;
}
enforce && now < st.blocked_until_ns
}
None => {
let st = SrcState {
window_start_ns: now,
count: 1,
blocked_until_ns: 0,
};
let _ = $map.insert(src, &st, 0);
false
}
}
}
};
}

count_src_for!(count_src_v4, [u8; 4], V4_SRC_COUNTERS);
count_src_for!(count_src_v6, [u8; 16], V6_SRC_COUNTERS);
src_gate_for!(src_gate_v4, [u8; 4], V4_SRC_STATE);
src_gate_for!(src_gate_v6, [u8; 16], V6_SRC_STATE);

/// Generate a learn-open-port function for one address family. Called from the
/// TC egress program with the local (source) address/port of an outbound
Expand Down Expand Up @@ -236,8 +278,6 @@ macro_rules! cidr_block_check {
};
}

cidr_block_check!(cidr_blocked_v4, [u8; 4], 32, V4_CIDR_SRC);
cidr_block_check!(cidr_blocked_v6, [u8; 16], 128, V6_CIDR_SRC);
cidr_block_check!(protected_v4, [u8; 4], 32, PROTECTED_V4);
cidr_block_check!(protected_v6, [u8; 16], 128, PROTECTED_V6);
cidr_block_check!(manual_blocked_v4, [u8; 4], 32, MANUAL_BLOCK_V4);
Expand Down
Loading
Loading