01 / architecture

Four layers. One verdict.

A multi-layer XDP shield. Userspace owns state. The program reads cfg.armed and, when armed, enforces a prefix-tree drop list. Thresholds do not live in BPF. Default verdict is XDP_PASS.

Four layers

  1. 01 · Watch

    Count and pass

    IDLE. XDP stays attached. rx_* then XDP_PASS. No Ethernet parse, no LPM, no host or remote maps. Arm on wake_pps / wake_mbps or ctl.

  2. 02 · Keep

    Never drop the VM

    Allow LPM, local-side TCP allow_ports, DHCP, IPv6 NDP 133–137 with hop limit 255, fe80::/10, ff02::/16. Userspace refuses a drop CIDR that covers local_* or allow_*.

  3. 03 · Cut

    Drop the hosts

    Drop LPM. A remote over threshold_pps / threshold_mbps becomes /32 or /128. Manual voidgatectl drop is this layer and does not expire.

  4. 04 · Widen

    Drop the cluster

    aggregate_k dropped hosts in a /24 or /64 install that prefix. Policy and aggregate live ban_time seconds. IPv4 and IPv6 together, always.

Two planes

Data plane

src/bpf/voidgate.bpf.c

GPL-2.0-only XDP program. Verdicts are XDP_PASS or XDP_DROP only. No AF_XDP, no redirect, no userspace packet path.

Control plane

src/voidgate.c + policy.c

Watches coarse rx rates, arms the gate, walks remote maps, and writes CIDRs into LPM drop tries. Apache-2.0.

Idle datapath

The cheap path is the product. On every packet the program bumps metrics.rx_pkts / rx_bytes, loads cfg.armed, and if it is zero returns XDP_PASS. It must not parse Ethernet, must not LPM, and must not touch host or remote maps.

Armed packet path

Whitelist checks run before the drop tree. Default verdict is still XDP_PASS.

  1. Parse — libpkt, the stack's parser: Ethernet, up to two VLAN tags, then IPv4 or IPv6. Non-IP is counted and passed. Bad or truncated headers increment parse_err. If the source and destination addresses are still readable (a bad IPv4 IHL or version, a truncated IPv6 extension), the drop tree still applies but no port exemption does; only a header too short to hold the addresses, or an IPv6 header of another version, is passed unjudged.
  2. IPv6 hard-pass — fe80::/10, ff02::/16, and ICMPv6 NDP types 133–137 with hop limit 255 skip the drop tree (routed traffic arrives below 255, so an off-link flood cannot use the exemption). IPv4 has no extra hard-pass here; link-local v4 is an allow-list entry.
  3. Allow LPM — source or destination in allow_v4 / allow_v6 skips the drop tree.
  4. TCP allow_ports — only when the local side is that port: dport and dest in local_*, or sport and src in local_*. A remote source port of 22 is not a whitelist hit.
  5. DHCP — exact port pair to this VM: UDP 67→68 or 68/67→67, DHCPv6 547→546 or 546/547→547. A remote source port alone is not a whitelist.
  6. Drop LPM — source or destination in drop_v4 / drop_v6 returns XDP_DROP.
  7. Count, then pass — inbound to a local dest updates host_* and remote_*; outbound from a local src updates host_*.

Maps

LPM lookup keys use prefixlen 32 or 128 for a host query. LPM_TRIE maps are created with BPF_F_NO_PREALLOC.

Map Type Written when Notes
cfg ARRAY[1] userspace armed, allow_ports
metrics PERCPU_ARRAY[1] always (rx); armed (dropped, …) userspace sums CPUs
drop_v4 / drop_v6 LPM_TRIE ACTIVE empty in IDLE after disarm
allow_v4 / allow_v6 LPM_TRIE start / reload metadata, localhost, link-local by default
local_v4 / local_v6 LPM_TRIE start / reload this VM’s CIDRs
host_v4 / host_v6 PERCPU_HASH armed local IPs
remote_v4 / remote_v6 LRU_PERCPU_HASH armed attack sources; not flushed on disarm

Idle ↔ Active

Idle

cfg.armed = 0

  • XDP attached, coarse rx counters only
  • No drop tree, no per-source maps
  • Userspace polls every idle_poll_ms
arm wake_pps / wake_mbps exceeded, or ctl arm / drop
disarm quiet for clear_seconds and drop tree empty, or ctl disarm

Active

cfg.armed = 1

  • Parse IPv4/IPv6, whitelist, drop LPM
  • Top remotes → /32 or /128
  • Dense clusters → /24 or /64

Policy, once a second

In ACTIVE, userspace walks remotes. A source over threshold_pps or threshold_mbps is inserted as /32 or /128 (reason=policy). If at least aggregate_k dropped hosts sit in the same /24 or /64, that prefix is inserted too (reason=aggregate). Policy and aggregate drops expire after ban_time. Manual drops do not. A timed drop (drop <cidr> ttl=<sec>, reason=4, from OpenResty) expires after its own ttl and is not counted for aggregation, so a few web bans behind one NAT never become a /24.

ACTIVE returns to IDLE when the NIC has been quiet for clear_seconds and the drop tree is empty. voidgatectl drop forces ACTIVE. voidgatectl disarm flushes drops and returns IDLE.

Do not blackhole the VM

  • Drop attacker prefixes, not the local interface CIDR.
  • Userspace refuses a drop CIDR that covers local_* or allow_*.
  • Whitelist / NDP / SSH / DHCP run before drop.
  • Shared NIC: this is not a SPAN tap. No promiscuous mode in v1.
  • IPv4 and IPv6 together. Dual LPM, dual host maps, dual remote maps.