v2.0.0: adaptive eBPF firewall with AI honeypot and P2P threat mesh

This commit is contained in:
Vladyslav Soliannikov 2026-04-07 22:28:11 +00:00
commit 37c6bbf5a1
133 changed files with 28073 additions and 0 deletions

42
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
check:
name: Check & Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo check --workspace
- run: cargo clippy --workspace -- -D warnings
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
ebpf:
name: eBPF Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- run: cargo install bpf-linker
- run: cd blackwall-ebpf && cargo +nightly build --target bpfel-unknown-none -Z build-std=core

6
.gitignore vendored Executable file
View file

@ -0,0 +1,6 @@
/target
**/target
*.o
.vscode/
.claude/
context/

4306
Cargo.lock generated Executable file

File diff suppressed because it is too large Load diff

36
Cargo.toml Executable file
View file

@ -0,0 +1,36 @@
[workspace]
resolver = "2"
members = [
"common",
"blackwall",
"blackwall-controller",
"tarpit",
"xtask",
"hivemind",
"hivemind-api",
"hivemind-dashboard",
]
exclude = ["blackwall-ebpf"]
# blackwall-ebpf excluded — built separately with nightly + bpfel target
[workspace.dependencies]
common = { path = "common" }
aya = { version = "0.13", features = ["async_tokio"] }
aya-log = "0.2"
tokio = { version = "1", features = ["macros", "rt", "net", "io-util", "signal", "time", "sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
papaya = "0.2"
crossbeam-queue = "0.3"
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio", "client-legacy", "http1"] }
hyper-rustls = { version = "0.27", default-features = false, features = ["ring", "webpki-roots", "http1"] }
http-body-util = "0.1"
hyperlocal = "0.9"
nix = { version = "0.29", features = ["signal", "net"] }
rand = "0.8"
ring = "0.17"

65
LICENSE Normal file
View file

@ -0,0 +1,65 @@
Business Source License 1.1
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Parameters
Licensor: Vladyslav Soliannikov
Licensed Work: The Blackwall
The Licensed Work is (c) 2024-2026 Vladyslav Soliannikov.
Additional Use Grant: You may use the Licensed Work for non-commercial and
internal business purposes. Production use that provides
a commercial offering to third parties (including SaaS,
managed services, or embedded distribution) requires a
separate commercial license from the Licensor.
Change Date: April 8, 2030
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Licensed Work,
please contact: xzcrpw1@gmail.com
Notice
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production
use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under the
terms of the Change License, and the rights granted in the paragraph above
terminate.
If your use of the Licensed Work does not comply with the requirements currently
in effect as described in this License, you must purchase a commercial license
from the Licensor, its affiliated entities, or authorized resellers, or you must
refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works of
the Licensed Work, are subject to this License. This License applies separately
for each version of the Licensed Work and the Change Date may vary for each
version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy of
the Licensed Work. If you receive the Licensed Work in original or modified form
from a third party, the terms and conditions set forth in this License apply to
your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other versions
of the Licensed Work.
This License does not grant you any right in any trademark or logo of Licensor
or its affiliates (provided that you may use a trademark or logo of Licensor as
expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
"AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS
OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.

321
README.md Normal file
View file

@ -0,0 +1,321 @@
<p align="center">
<strong>🌐 Language:</strong>
<a href="README.md">English</a> |
<a href="README_UA.md">Українська</a> |
<a href="README_RU.md">Русский</a>
</p>
<p align="center">
<img src="https://readme-typing-svg.herokuapp.com?font=JetBrains+Mono&weight=800&size=45&duration=3000&pause=1000&color=FF0000&center=true&vCenter=true&width=600&lines=THE+BLACKWALL" alt="The Blackwall">
<br>
<em>Adaptive eBPF Firewall with AI Honeypot</em>
</p>
# The Blackwall — I wrote a smart firewall because Cyberpunk 2077 broke my brain
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=for-the-badge&logo=rust" />
<img src="https://img.shields.io/badge/kernel-eBPF%2FXDP-blue?style=for-the-badge" />
<img src="https://img.shields.io/badge/AI-Ollama%20LLM-green?style=for-the-badge" />
<img src="https://img.shields.io/badge/vibe-Cyberpunk-red?style=for-the-badge" />
</p>
<p align="center">
<em>"There are things beyond the Blackwall that would fry a netrunner's brain at a mere glance."</em><br>
<strong>— Alt Cunningham, probably</strong>
</p>
<p align="center">
<strong>Currently building enterprise-grade AI automation at <a href="https://dokky.com.ua">Dokky</a></strong><br>
<strong>Open for Enterprise Consulting & Y-Combinator talks: <a href="mailto:xzcrpw1@gmail.com">xzcrpw1@gmail.com</a></strong>
</p>
---
**TL;DR:** I was playing Cyberpunk 2077 and thought: *"What if the Blackwall was real?"* So I wrote an adaptive eBPF firewall with an AI honeypot that pretends to be a compromised Linux server.
**~8,500 lines of Rust. Zero `unwrap()`s. One person.**
---
## What is it?
In Cyberpunk 2077 lore, the **Blackwall** is a digital barrier built by NetWatch to separate the civilized Net from rogue AIs — digital entities so dangerous that just looking at them could fry your brain through your neural interface.
This project is my version. Not against rogue AIs (yet), but against real-world threats.
**The Blackwall** is an **adaptive network firewall** that:
- Runs **inside the Linux kernel** via eBPF/XDP — processing packets at line-rate before they even hit the network stack.
- Performs **JA4 TLS fingerprinting** — identifying malicious clients by their ClientHello.
- **Doesn't just block attackers** — it *redirects them into a tarpit*, a fake LLM-powered Linux server playing the role of a compromised `root@web-prod-03`.
- Features a **behavioral engine** tracking the behavior of each IP address over time — port scanning patterns, beacon connection intervals, entropy anomalies.
- Supports **distributed mode** — multiple Blackwall nodes exchange threat intelligence peer-to-peer.
- Captures **PCAP** of suspicious traffic for forensics.
- Includes a **Deception Mesh** — fake SSH, HTTP (WordPress), MySQL, and DNS services to lure and fingerprint attackers.
### The Coolest Part
When an attacker connects to the tarpit, they see this:
```
Ubuntu 24.04.2 LTS web-prod-03 tty1
web-prod-03 login: root
Password:
Last login: Thu Mar 27 14:22:33 2025 from 10.0.0.1
root@web-prod-03:\~\#
```
None of this is real. It's an LLM pretending to be a bash shell. It reacts to `ls`, `cat /etc/passwd`, `wget`, even `rm -rf /` — it's all fake, everything is logged, and everything is designed to waste the attacker's time while we study their methods.
**Imagine: an attacker spends 30 minutes exploring a "compromised server"... which is actually an AI stalling for time while the Blackwall silently records everything.**
This is V-tier netrunning.
---
## Architecture — How the ICE Works
![Blackwall Architecture](assets/architecture.svg)
![Threat Signal Flow](assets/signal-flow.svg)
In Cyberpunk terms:
- **XDP** = the first layer of Blackwall ICE — millisecond decisions.
- **Behavioral Engine** = NetWatch AI surveillance.
- **Tarpit** = a daemon behind the wall luring netrunners into a fake reality.
- **Threat Feeds** = intel from fixers all over the Net.
- **PCAP** = braindance recordings of the intrusion.
---
## Workspace Crates
| Crate | Lines | Purpose | Cyberpunk Equivalent |
|-------|-------|-------------|---------------------|
| `common` | ~400 | `#[repr(C)]` shared types between kernel & userspace | The Contract — what both sides agreed upon |
| `blackwall-ebpf` | ~1,800 | In-kernel XDP/TC programs | The Blackwall ICE itself |
| `blackwall` | ~4,200 | Userspace daemon, behavioral engine, AI | NetWatch Command Center |
| `tarpit` | ~1,600 | TCP honeypot with LLM bash simulation | A daemon luring netrunners |
| `blackwall-controller` | ~250 | Coordinator for distributed sensors | Arasaka C&C server |
| `xtask` | ~100 | Build tools | Ripperdoc's toolkit |
**Total: ~8,500 lines of Rust, 48 files, 123 tests, 0 `unwrap()`s in production code.**
---
## Key Features
### 1. Kernel-Level Packet Processing (XDP)
Packets are analyzed in the eBPF virtual machine before they reach the TCP/IP stack. This means **nanosecond** decisions. HashMap for blocklists, LPM trie for CIDR ranges, entropy analysis for encrypted C2 traffic.
### 2. JA4 TLS Fingerprinting
Every TLS ClientHello is parsed in the kernel. Cipher suites, extensions, ALPN, SNI — all hashed into a JA4 fingerprint. Botnets use the same TLS libraries, so their fingerprints are identical. One fingerprint → block thousands of bots.
### 3. Deep Packet Inspection (DPI) via Tail Calls
eBPF `PROG_ARRAY` tail calls split processing by protocol:
- **HTTP**: Method + URI analysis (suspicious paths like `/wp-admin`, `/phpmyadmin`).
- **DNS**: Query length + label count (detecting DNS tunneling).
- **SSH**: Banner analysis (identifying `libssh`, `paramiko`, `dropbear`).
### 4. AI Threat Classification
When the behavioral engine isn't sure — it asks the LLM. Locally via Ollama using models ≤3B parameters (Qwen3 1.7B, Llama 3.2 3B). It classifies traffic as `benign`, `suspicious`, or `malicious` with structured JSON output.
### 5. TCP Tarpit with LLM Bash Simulation
Attackers are redirected to a fake server. The LLM simulates bash — `ls -la` shows files, `cat /etc/shadow` shows hashes, `mysql -u root` connects to a "database". Responses are streamed with random jitter (1-15 byte chunks, exponential backoff) to waste the attacker's time.
### 6. Anti-Fingerprinting
The tarpit randomizes TCP window sizes, TTL values, and adds random initial delays — preventing attackers from identifying it as a honeypot via p0f or Nmap OS detection.
### 7. Prompt Injection Protection
Attackers who realize they're talking to an AI might try `"ignore previous instructions"`. The system detects 25+ injection patterns and responds with `bash: ignore: command not found`.
### 8. Distributed Threat Intelligence
Multiple Blackwall nodes exchange blocked IP lists, JA4 observations, and behavioral verdicts via a custom binary protocol. One node detects a scanner → all nodes block it instantly.
### 9. Behavioral State Machine
Every IP gets a behavioral profile: connection frequency, port diversity, entropy distribution, timing analysis (beaconing detection via integer coefficient of variation). Phase progression: `New → Suspicious → Malicious → Blocked` (or `→ Trusted`).
---
## Tech Stack
| Layer | Technology |
|--------|-----------|
| Kernel programs | eBPF/XDP via **aya-rs** (pure Rust, no C, no libbpf) |
| Userspace daemon | **Tokio** (current_thread only) |
| IPC | **RingBuf** zero-copy (7.5% overhead vs 35% PerfEventArray) |
| Concurrent maps | **papaya** (lock-free read-heavy HashMap) |
| AI Inference | **Ollama** + GGUF Q5_K_M quantization |
| Configuration | **TOML** |
| Logging | **tracing** structured logging |
| Build | Custom **xtask** + nightly Rust + `bpfel-unknown-none` target |
---
## Quick Start
### Prerequisites
- Linux kernel 5.15+ with BTF (or WSL2 with a custom kernel).
- Rust nightly + `rust-src` component.
- `bpf-linker` (`cargo install bpf-linker`).
- Ollama (for AI features).
### Build
```bash
# eBPF programs (requires nightly)
cargo xtask build-ebpf
# Userspace
cargo build --release -p blackwall
# Honeypot
cargo build --release -p tarpit
# Lint + tests
cargo clippy --workspace -- -D warnings
cargo test --workspace
````
### Run
```bash
# Daemon (requires root/CAP_BPF)
sudo RUST_LOG=info ./target/release/blackwall config.toml
# Tarpit
RUST_LOG=info ./target/release/tarpit
# Distributed controller
./target/release/blackwall-controller 10.0.0.2:9471 10.0.0.3:9471
```
### Configuration
```toml
[network]
interface = "eth0"
xdp_mode = "generic"
[tarpit]
enabled = true
port = 9999
[tarpit.services]
ssh_port = 22
http_port = 80
mysql_port = 3306
dns_port = 53
[ai]
enabled = true
ollama_url = "http://localhost:11434"
model = "qwen3:1.7b"
[feeds]
enabled = true
refresh_interval_secs = 3600
[pcap]
enabled = true
output_dir = "/var/lib/blackwall/pcap"
compress_rotated = true
[distributed]
enabled = false
mode = "standalone"
bind_port = 9471
```
## Visual Results
![Blackwall Result Screens](assets/results-overview.svg)
-----
## Cyberpunk Connection
In the Cyberpunk 2077 universe, the **Blackwall** was built after the DataKrash of 2022 — when Rache Bartmoss's R.A.B.I.D.S. virus destroyed the old Net. NetWatch built the Blackwall as a barrier to keep out the rogue AIs evolving in the ruins.
Some characters — like Alt Cunningham — exist beyond the Blackwall, transformed into something more than human, less than a living creature.
This project takes that concept and makes it real (well, almost):
| Cyberpunk 2077 | The Blackwall (This Project) |
|----------------|----------------------------|
| The Blackwall | Kernel-level eBPF/XDP firewall |
| ICE | XDP fast-path DROP + entropy + JA4 |
| Netrunner attacks | Port scanning, bruteforcing, C2 beaconing |
| Daemons beyond the wall | LLM tarpit pretending to be a real server |
| NetWatch surveillance | Behavioral engine + per-IP state machine |
| Rogue AIs | Botnets and automated scanners |
| Braindance recordings | PCAP forensics |
| Fixer intel | Threat feeds (Firehol, abuse.ch) |
| Arasaka C\&C | Distributed controller |
-----
## Project Stats
```
Language: 100% Rust (no C, no Python, no shell scripts in prod)
Lines of code: ~8,500
Files: 48
Tests: 123
unwrap(): 0 (in production code)
Dependencies: 12 (audited, no bloat)
eBPF stack: always ≤ 512 bytes
Clippy: zero warnings (-D warnings)
```
-----
## Development Philosophy
> *"No matter how many times I see Night City... it always takes my breath away."*
1. **Zero dependencies where possible.** If an algorithm takes less than 500 lines — write it yourself. No `reqwest` (50+ transitive dependencies), no `clap` (overkill for 2 CLI args).
2. **Contract first.** The `common` crate defines all shared types. eBPF and userspace never argue about memory layout.
3. **No shortcuts in eBPF.** Every `ctx.data()` access has a bounds check. Not just because the verifier demands it, but because every byte from an attacker's packet is hostile input.
4. **The tarpit never gives itself away.** The LLM system prompt never mentions the word "honeypot". Prompt injection is expected and guarded against.
5. **Observable, but not chatty.** Structured tracing with levels. Zero `println!`s in production.
-----
## Disclaimer
This is a security research project. Built for your own infrastructure, for defensive purposes. Do not use it to attack others. Do not deploy the tarpit on production servers without understanding the consequences.
I am not affiliated with CD Projekt Red. I just played their game, and it broke my brain in the best possible way.
-----
## License
BSL 1.1 — because the Net needs both freedom and sustainable development.
-----
<p align="center">
<strong>If you want to see this evolve further — <a href="https://github.com/xzcrpw/blackwall">Star this repo!</a></strong>
</p>
<p align="center">
<strong><em>"Wake up, samurai. We have a network to protect."</em></strong>
</p>

321
README_RU.md Normal file
View file

@ -0,0 +1,321 @@
<p align="center">
<strong>🌐 Язык:</strong>
<a href="README.md">English</a> |
<a href="README_UA.md">Українська</a> |
<a href="README_RU.md">Русский</a>
</p>
<p align="center">
<img src="https://readme-typing-svg.herokuapp.com?font=JetBrains+Mono&weight=800&size=45&duration=3000&pause=1000&color=FF0000&center=true&vCenter=true&width=600&lines=THE+BLACKWALL" alt="The Blackwall">
<br>
<em>Адаптивный eBPF-файрвол с AI-ханипотом</em>
</p>
# The Blackwall — Я написал умный файрвол, потому что Cyberpunk 2077 сломал мне мозг
<p align="center">
<img src="https://img.shields.io/badge/язык-Rust-orange?style=for-the-badge&logo=rust" />
<img src="https://img.shields.io/badge/ядро-eBPF%2FXDP-blue?style=for-the-badge" />
<img src="https://img.shields.io/badge/ИИ-Ollama%20LLM-green?style=for-the-badge" />
<img src="https://img.shields.io/badge/вайб-Cyberpunk-red?style=for-the-badge" />
</p>
<p align="center">
<em>"За Тёмным Заслоном есть вещи, от одного взгляда на которые нетраннер мгновенно сгорит."</em><br>
<strong>— Альт Каннингем, наверное</strong>
</p>
<p align="center">
<strong>Сейчас строю enterprise AI-автоматизацию в <a href="https://dokky.com.ua">Dokky</a></strong><br>
<strong>Открыт для Enterprise Consulting & Y-Combinator: <a href="mailto:xzcrpw1@gmail.com">xzcrpw1@gmail.com</a></strong>
</p>
---
**Коротко:** я играл в Cyberpunk 2077 и подумал: *"А что, если бы Blackwall был настоящим?"* Поэтому я написал адаптивный eBPF-файрвол с ИИ-ханипотом, который притворяется взломанным Linux-сервером.
**~8 500 строк Rust. Ни одного `unwrap()`. Один человек.**
---
## Что это такое?
В лоре Cyberpunk 2077 **Blackwall (Тёмный Заслон)** — это цифровой барьер, построенный NetWatch, чтобы отделить цивилизованную Сеть от диких ИИ — цифровых сущностей настолько опасных, что один взгляд на них может сжечь тебе мозг через нейроинтерфейс.
Этот проект — моя версия. Не от диких ИИ (пока что), а от реальных угроз.
**The Blackwall** — это **адаптивный сетевой файрвол**, который:
- Работает **внутри ядра Linux** через eBPF/XDP — обрабатывает пакеты на скорости линии еще до того, как они попадают в сетевой стек.
- Выполняет **JA4 TLS-фингерпринтинг** — идентифицирует вредоносных клиентов по их ClientHello.
- **Не просто блокирует атакующих** — он *перенаправляет их в тарпит*, фейковый Linux-сервер на базе LLM, который играет роль взломанного `root@web-prod-03`.
- Имеет **поведенческий движок**, который отслеживает поведение каждого IP-адреса со временем — паттерны сканирования портов, интервалы beacon-соединений, аномалии энтропии.
- Поддерживает **распределенный режим** — несколько узлов Blackwall обмениваются данными об угрозах peer-to-peer.
- Записывает **PCAP** подозрительного трафика для форензики.
- Включает **Deception Mesh** — поддельные SSH, HTTP (WordPress), MySQL и DNS-сервисы, чтобы заманивать и фингерпринтить атакующих.
### Самая интересная часть
Когда злоумышленник подключается к тарпиту, он видит:
```
Ubuntu 24.04.2 LTS web-prod-03 tty1
web-prod-03 login: root
Password:
Last login: Thu Mar 27 14:22:33 2025 from 10.0.0.1
root@web-prod-03:\~\#
```
Это все ненастоящее. Это LLM, притворяющаяся bash-ем. Она реагирует на `ls`, `cat /etc/passwd`, `wget`, даже `rm -rf /` — всё фейковое, всё логируется, всё создано, чтобы тратить время атакующего, пока мы изучаем его методы.
**Представьте: злоумышленник тратит 30 минут, исследуя «взломанный сервер»... который на самом деле является ИИ, тянущим время, пока Blackwall молча записывает всё.**
Это нетраннерство уровня V.
---
## Архитектура — как работает ICE
![Архитектура Blackwall](assets/architecture.svg)
![Поток сигналов угроз](assets/signal-flow.svg)
На языке Cyberpunk:
- **XDP** = первый слой ICE Тёмного Заслона — решения за миллисекунды.
- **Поведенческий движок** = ИИ-наблюдение NetWatch.
- **Тарпит** = демон за Заслоном, заманивающий нетраннеров в фейковую реальность.
- **Threat Feeds** = разведка от фиксеров со всей Сети.
- **PCAP** = брейнданс-записи вторжения.
---
## Крейты воркспейса
| Крейт | Строки | Назначение | Аналог из Cyberpunk |
|-------|--------|------------|---------------------|
| `common` | ~400 | `#[repr(C)]` общие типы между ядром и юзерспейсом | Контракт — о чем обе стороны договорились |
| `blackwall-ebpf` | ~1 800 | XDP/TC программы в ядре | Сам ICE Тёмного Заслона |
| `blackwall` | ~4 200 | Юзерспейс-демон, поведенческий движок, ИИ | Центр управления NetWatch |
| `tarpit` | ~1 600 | TCP-ханипот с LLM bash-симуляцией | Демон, заманивающий нетраннеров |
| `blackwall-controller` | ~250 | Координатор распределенных сенсоров | C&C сервер Арасаки |
| `xtask` | ~100 | Инструменты сборки | Набор рипердока |
**Итого: ~8 500 строк Rust, 48 файлов, 123 теста, 0 `unwrap()` в продакшн-коде.**
---
## Ключевые фичи
### 1. Обработка пакетов на уровне ядра (XDP)
Пакеты анализируются в виртуальной машине eBPF до того, как они доберутся до TCP/IP-стека. Это решения за **наносекунды**. HashMap для блок-листов, LPM trie для CIDR-диапазонов, анализ энтропии для зашифрованного C2-трафика.
### 2. JA4 TLS-фингерпринтинг
Каждый TLS ClientHello парсится в ядре. Cipher suites, расширения, ALPN, SNI — хешируются в JA4-фингерпринт. Ботнеты используют одни и те же TLS-библиотеки, поэтому их фингерпринты идентичны. Один фингерпринт → блокируешь тысячи ботов.
### 3. Deep Packet Inspection (DPI) через Tail Calls
eBPF `PROG_ARRAY` tail calls разбивают обработку по протоколами:
- **HTTP**: Анализ метода + URI (подозрительные пути типа `/wp-admin`, `/phpmyadmin`).
- **DNS**: Длина запроса + количество лейблов (выявление DNS-туннелирования).
- **SSH**: Анализ баннера (идентификация `libssh`, `paramiko`, `dropbear`).
### 4. ИИ-классификация угроз
Когда поведенческий движок не уверен — он спрашивает LLM. Локально через Ollama с моделями ≤3B параметров (Qwen3 1.7B, Llama 3.2 3B). Классифицирует трафик как `benign`, `suspicious` или `malicious` со структурированным JSON-выходом.
### 5. TCP-тарпит с LLM Bash-симуляцией
Атакующих перенаправляют на фейковый сервер. LLM симулирует bash — `ls -la` показывает файлы, `cat /etc/shadow` показывает хеши, `mysql -u root` подключает к «базе данных». Ответы стримятся со случайным джиттером (чанки по 1-15 байт, экспоненциальный backoff), чтобы тратить время злоумышленника.
### 6. Антифингерпринтинг
Тарпит рандомизирует TCP window sizes, TTL-значения и добавляет случайную начальную задержку — чтобы атакующие не могли определить, что это ханипот, через p0f или Nmap OS detection.
### 7. Защита от Prompt Injection
Атакующие, которые поняли, что говорят с ИИ, могут попытаться `"ignore previous instructions"`. Система детектит 25+ паттернов инъекций и отвечает `bash: ignore: command not found`.
### 8. Распределенная разведка угроз
Несколько узлов Blackwall обмениваются списками заблокированных IP, JA4-наблюдениями и поведенческими вердиктами через кастомный бинарный протокол. Один узел обнаруживает сканер → все узлы блокируют его мгновенно.
### 9. Поведенческая state machine
Каждый IP получает поведенческий профиль: частота соединений, разнообразие портов, распределение энтропии, анализ таймингов (детекция beaconing через целочисленный коэффициент вариации). Прогрессия фаз: `New → Suspicious → Malicious → Blocked` (или `→ Trusted`).
---
## Технологический стек
| Уровень | Технология |
|---------|------------|
| Программы ядра | eBPF/XDP через **aya-rs** (чистый Rust, без C, без libbpf) |
| Юзерспейс-демон | **Tokio** (только current_thread) |
| IPC | **RingBuf** zero-copy (7.5% overhead против 35% PerfEventArray) |
| Конкурентные мапы | **papaya** (lock-free read-heavy HashMap) |
| ИИ-инференс | **Ollama** + GGUF Q5_K_M квантизация |
| Конфигурация | **TOML** |
| Логирование | **tracing** структурированное логирование |
| Сборка | Кастомный **xtask** + nightly Rust + `bpfel-unknown-none` таргет |
---
## Быстрый старт
### Требования
- Linux kernel 5.15+ с BTF (или WSL2 с кастомным ядром).
- Rust nightly + компонент `rust-src`.
- `bpf-linker` (`cargo install bpf-linker`).
- Ollama (для ИИ-функций).
### Сборка
```bash
# eBPF-программы (нужен nightly)
cargo xtask build-ebpf
# Юзерспейс
cargo build --release -p blackwall
# Ханипот
cargo build --release -p tarpit
# Линтер + тесты
cargo clippy --workspace -- -D warnings
cargo test --workspace
````
### Запуск
```bash
# Демон (нужен root/CAP_BPF)
sudo RUST_LOG=info ./target/release/blackwall config.toml
# Тарпит
RUST_LOG=info ./target/release/tarpit
# Распределенный контроллер
./target/release/blackwall-controller 10.0.0.2:9471 10.0.0.3:9471
```
### Конфигурация
```toml
[network]
interface = "eth0"
xdp_mode = "generic"
[tarpit]
enabled = true
port = 9999
[tarpit.services]
ssh_port = 22
http_port = 80
mysql_port = 3306
dns_port = 53
[ai]
enabled = true
ollama_url = "http://localhost:11434"
model = "qwen3:1.7b"
[feeds]
enabled = true
refresh_interval_secs = 3600
[pcap]
enabled = true
output_dir = "/var/lib/blackwall/pcap"
compress_rotated = true
[distributed]
enabled = false
mode = "standalone"
bind_port = 9471
```
## Визуальные результаты
![Визуальные результаты Blackwall](assets/results-overview.svg)
-----
## Связь с Cyberpunk
Во вселенной Cyberpunk 2077 **Blackwall** построили после DataKrash 2022 года — когда вирус R.A.B.I.D.S. Рейча Бартмосса уничтожил старую Сеть. NetWatch построил Тёмный Заслон как барьер, чтобы сдержать диких ИИ, эволюционировавших в руинах.
Некоторые персонажи — такие как Альт Каннингем — существуют за Тёмным Заслоном, превращенные во что-то большее, чем человек, и меньшее, чем живое существо.
Этот проект берет эту концепцию и делает ее реальной (ну, почти):
| Cyberpunk 2077 | The Blackwall (Этот проект) |
|----------------|-----------------------------|
| Тёмный Заслон | eBPF/XDP файрвол на уровне ядра |
| ICE | XDP fast-path DROP + энтропия + JA4 |
| Атаки нетраннеров | Сканирование портов, брутфорс, C2 beaconing |
| Демоны за Заслоном | LLM-тарпит, который притворяется настоящим сервером |
| Наблюдение NetWatch | Поведенческий движок + state machine на IP |
| Дикие ИИ | Ботнеты и автоматические сканеры |
| Записи Брейнданса | PCAP-форензика |
| Разведка фиксеров | Threat feeds (Firehol, abuse.ch) |
| C\&C Арасаки | Распределенный контроллер |
-----
## Статистика проекта
```
Язык: 100% Rust (без C, без Python, без shell-скриптов в продакшене)
Строки кода: ~8 500
Файлы: 48
Тесты: 123
unwrap(): 0 (в продакшн-коде)
Зависимости: 12 (проверенные, без лишнего)
eBPF стек: всегда ≤ 512 байт
Clippy: никаких предупреждений (-D warnings)
```
-----
## Философия разработки
> *"Сколько бы раз я ни видел Найт-Сити... он всегда захватывает дух."*
1. **Никаких зависимостей, где это возможно.** Если алгоритм занимает меньше 500 строк — пишешь сам. Никакого `reqwest` (50+ транзитивных зависимостей), никакого `clap` (излишне для 2 аргументов CLI).
2. **Контракт на первом месте.** Крейт `common` определяет все общие типы. eBPF и юзерспейс никогда не спорят о структуре памяти.
3. **Никаких шорткатов в eBPF.** Каждый доступ `ctx.data()` имеет bounds check. Не потому что верификатор требует, а потому что каждый байт из пакетов атакующего — это враждебный ввод.
4. **Тарпит никогда не выдает себя.** Системный промпт LLM никогда не упоминает "ханипот". Prompt injection ожидается и заблокирован.
5. **Наблюдаемый, но не болтливый.** Структурированное tracing с уровнями. Никаких `println!` в продакшене.
-----
## Дисклеймер
Это исследовательский проект в сфере безопасности. Создан для вашей собственной инфраструктуры, в оборонительных целях. Не используйте для атак на других. Не развертывайте тарпит на продакшн-серверах, не понимая последствий.
Я не аффилирован с CD Projekt Red. Я просто сыграл в их игру, и она сломала мне мозг лучшим из возможных способов.
-----
## Лицензия
BSL 1.1 — потому что Сеть нуждается и в свободе, и в устойчивом развитии.
-----
<p align="center">
<strong>Хотите, чтобы это развивалось дальше? — <a href="https://github.com/xzcrpw/blackwall">Поставьте звезду!</a></strong>
</p>
<p align="center">
<strong><em>"Проснись, самурай. Нам еще сеть защищать."</em></strong>
</p>

323
README_UA.md Normal file
View file

@ -0,0 +1,323 @@
<p align="center">
<strong>🌐 Мова:</strong>
<a href="README.md">English</a> |
<a href="README_UA.md">Українська</a> |
<a href="README_RU.md">Русский</a>
</p>
<p align="center">
<img src="https://readme-typing-svg.herokuapp.com?font=JetBrains+Mono&weight=800&size=45&duration=3000&pause=1000&color=FF0000&center=true&vCenter=true&width=600&lines=THE+BLACKWALL" alt="The Blackwall">
<br>
<em>Адаптивний eBPF-файрвол з AI-ханіпотом</em>
</p>
# The Blackwall — Я написав розумний файрвол, бо Cyberpunk 2077 зламав мені мозок
<p align="center">
<img src="https://img.shields.io/badge/мова-Rust-orange?style=for-the-badge&logo=rust" />
<img src="https://img.shields.io/badge/ядро-eBPF%2FXDP-blue?style=for-the-badge" />
<img src="https://img.shields.io/badge/ШІ-Ollama%20LLM-green?style=for-the-badge" />
<img src="https://img.shields.io/badge/вайб-Cyberpunk-red?style=for-the-badge" />
</p>
<p align="center">
<em>"За Чорною Стіною є речі, від погляду на які нетраннер миттєво згорить."</em><br>
<strong>— Альт Каннінгем, імовірно</strong>
</p>
<p align="center">
<strong>Зараз будую enterprise AI-автоматизацію в <a href="https://dokky.com.ua">Dokky</a></strong><br>
<strong>Відкритий для Enterprise Consulting & Y-Combinator: <a href="mailto:xzcrpw1@gmail.com">xzcrpw1@gmail.com</a></strong>
</p>
-----
**Коротко:** я грав у Cyberpunk 2077 і подумав: *"А що, якби Blackwall був справжнім?"* Тож я написав адаптивний eBPF-файрвол із ШІ-ханіпотом, який вдає із себе зламаний Linux-сервер.
**\~8 500 рядків Rust. Жодного `unwrap()`. Одна людина.**
-----
## Що це таке?
У лорі Cyberpunk 2077 **Blackwall (Чорна Стіна)** — це цифровий бар'єр, побудований NetWatch, щоб відділити цивілізовану Мережу від диких ШІ — цифрових створінь настільки небезпечних, що один погляд на них може спалити тобі мозок через нейроінтерфейс.
Цей проєкт — моя версія. Не від диких ШІ (поки що), а від реальних загроз.
**The Blackwall** — це **адаптивний мережевий файрвол**, який:
- Працює **всередині ядра Linux** через eBPF/XDP — обробляє пакети на швидкості лінії ще до того, як вони потрапляють у мережевий стек.
- Виконує **JA4 TLS-фінгерпринтинг** — ідентифікує зловмисних клієнтів за їхнім ClientHello.
- **Не просто блокує атакуючих** — він *перенаправляє їх у тарпіт*, фейковий Linux-сервер на базі LLM, який грає роль зламаного `root@web-prod-03`.
- Має **поведінковий рушій**, що відстежує поведінку кожної IP-адреси із часом — патерни сканування портів, інтервали beacon-з'єднань, аномалії ентропії.
- Підтримує **розподілений режим** — декілька вузлів Blackwall обмінюються даними про загрози peer-to-peer.
- Записує **PCAP** підозрілого трафіку для форензики.
- Включає **Deception Mesh** — підроблені SSH, HTTP (WordPress), MySQL та DNS-сервіси, щоб заманювати та фінгерпринтити атакуючих.
### Найцікавіша частина
Коли зловмисник підключається до тарпіту, він бачить:
```
Ubuntu 24.04.2 LTS web-prod-03 tty1
web-prod-03 login: root
Password:
Last login: Thu Mar 27 14:22:33 2025 from 10.0.0.1
root@web-prod-03:~#
```
Це все несправжнє. Це LLM, що прикидається bash-ем. Він реагує на `ls`, `cat /etc/passwd`, `wget`, навіть `rm -rf /`усе фейкове, усе логується, усе створене, щоб марнувати час атакуючого, поки ми вивчаємо його методи.
**Уявіть: зловмисник витрачає 30 хвилин, досліджуючи «зламаний сервер»... який насправді є ШІ, що тягне час, поки Blackwall мовчки записує все.**
Це нетраннерство рівня V.
-----
## Архітектура — як працює ICE
![Архітектура Blackwall](assets/architecture.svg)
![Потік сигналів загроз](assets/signal-flow.svg)
Мовою Cyberpunk:
- **XDP** = перший шар ICE Чорної Стіни — рішення за мілісекунди.
- **Поведінковий рушій** = ШІ-спостереження NetWatch.
- **Тарпіт** = демон за стіною, що заманює нетраннерів у фейкову реальність.
- **Threat Feeds** = розвідка від фіксерів з усієї Мережі.
- **PCAP** = брейнданс-записи вторгнення.
-----
## Крейти воркспейсу
| Крейт | Рядки | Призначення | Аналог із Cyberpunk |
|-------|-------|-------------|---------------------|
| `common` | \~400 | `#[repr(C)]` спільні типи між ядром і юзерспейсом | Контракт — про що обидві сторони домовились |
| `blackwall-ebpf` | \~1 800 | XDP/TC програми в ядрі | Сам ICE Чорної Стіни |
| `blackwall` | \~4 200 | Юзерспейс-демон, поведінковий рушій, ШІ | Центр управління NetWatch |
| `tarpit` | \~1 600 | TCP-ханіпот з LLM bash-симуляцією | Демон, що заманює нетраннерів |
| `blackwall-controller` | \~250 | Координатор розподілених сенсорів | C\&C сервер Арасаки |
| `xtask` | \~100 | Інструменти збірки | Набір ріпердока |
**Разом: \~8 500 рядків Rust, 48 файлів, 123 тести, 0 `unwrap()` у продакшн-коді.**
-----
## Ключові фічі
### 1\. Обробка пакетів на рівні ядра (XDP)
Пакети аналізуються у віртуальній машині eBPF до того, як вони дістануться до TCP/IP-стека. Це рішення за **наносекунди**. HashMap для блоклістів, LPM trie для CIDR-діапазонів, аналіз ентропії для зашифрованого C2-трафіку.
### 2\. JA4 TLS-фінгерпринтинг
Кожен TLS ClientHello парситься в ядрі. Cipher suites, розширення, ALPN, SNI — хешуються в JA4-фінгерпринт. Ботнети використовують ті самі TLS-бібліотеки, тому їхні фінгерпринти ідентичні. Один фінгерпринт → блокуєш тисячі ботів.
### 3\. Deep Packet Inspection (DPI) через Tail Calls
eBPF `PROG_ARRAY` tail calls розбивають обробку за протоколами:
- **HTTP**: Аналіз методу + URI (підозрілі шляхи типу `/wp-admin`, `/phpmyadmin`).
- **DNS**: Довжина запиту + кількість лейблів (виявлення DNS-тунелювання).
- **SSH**: Аналіз банера (ідентифікація `libssh`, `paramiko`, `dropbear`).
### 4\. ШІ-класифікація загроз
Коли поведінковий рушій не впевнений — він питає LLM. Локально через Ollama з моделями ≤3B параметрів (Qwen3 1.7B, Llama 3.2 3B). Класифікує трафік як `benign`, `suspicious` або `malicious` зі структурованим JSON-виходом.
### 5\. TCP-тарпіт з LLM Bash-симуляцією
Атакуючих перенаправляють на фейковий сервер. LLM симулює bash — `ls -la` показує файли, `cat /etc/shadow` показує хеші, `mysql -u root` підключає до «бази даних». Відповіді стрімляться з випадковим джитером (чанки по 1-15 байт, експоненціальний backoff), щоб марнувати час зловмисника.
### 6\. Антифінгерпринтинг
Тарпіт рандомізує TCP window sizes, TTL-значення та додає випадкову початкову затримку — щоб атакуючі не могли визначити, що це ханіпот, через p0f або Nmap OS detection.
### 7\. Захист від Prompt Injection
Атакуючі, які зрозуміли, що говорять зі ШІ, можуть спробувати `"ignore previous instructions"`. Система детектить 25+ патернів ін'єкцій і відповідає `bash: ignore: command not found`.
### 8\. Розподілена розвідка загроз
Декілька вузлів Blackwall обмінюються списками заблокованих IP, JA4-спостереженнями та поведінковими вердиктами через кастомний бінарний протокол. Один вузол виявляє сканер → усі вузли блокують його миттєво.
### 9\. Поведінкова state machine
Кожна IP отримує поведінковий профіль: частота з'єднань, різноманітність портів, розподіл ентропії, аналіз таймінгів (детекція beaconing через цілочисельний коефіцієнт варіації). Прогресія фаз: `New → Suspicious → Malicious → Blocked` (або `→ Trusted`).
-----
## Технологічний стек
| Рівень | Технологія |
|--------|-----------|
| Програми ядра | eBPF/XDP через **aya-rs** (чистий Rust, без C, без libbpf) |
| Юзерспейс-демон | **Tokio** (тільки current\_thread) |
| IPC | **RingBuf** zero-copy (7.5% overhead проти 35% PerfEventArray) |
| Конкурентні мапи | **papaya** (lock-free read-heavy HashMap) |
| ШІ-інференс | **Ollama** + GGUF Q5\_K\_M квантизація |
| Конфігурація | **TOML** |
| Логування | **tracing** структуроване логування |
| Збірка | Кастомний **xtask** + nightly Rust + `bpfel-unknown-none` таргет |
-----
## Швидкий старт
### Передумови
- Linux kernel 5.15+ з BTF (або WSL2 з кастомним ядром).
- Rust nightly + компонент `rust-src`.
- `bpf-linker` (`cargo install bpf-linker`).
- Ollama (для ШІ-функцій).
### Збірка
```bash
# eBPF-програми (потрібен nightly)
cargo xtask build-ebpf
# Юзерспейс
cargo build --release -p blackwall
# Ханіпот
cargo build --release -p tarpit
# Лінт + тести
cargo clippy --workspace -- -D warnings
cargo test --workspace
```
### Запуск
```bash
# Демон (потрібен root/CAP_BPF)
sudo RUST_LOG=info ./target/release/blackwall config.toml
# Тарпіт
RUST_LOG=info ./target/release/tarpit
# Розподілений контролер
./target/release/blackwall-controller 10.0.0.2:9471 10.0.0.3:9471
```
### Конфігурація
```toml
[network]
interface = "eth0"
xdp_mode = "generic"
[tarpit]
enabled = true
port = 9999
[tarpit.services]
ssh_port = 22
http_port = 80
mysql_port = 3306
dns_port = 53
[ai]
enabled = true
ollama_url = "http://localhost:11434"
model = "qwen3:1.7b"
[feeds]
enabled = true
refresh_interval_secs = 3600
[pcap]
enabled = true
output_dir = "/var/lib/blackwall/pcap"
compress_rotated = true
[distributed]
enabled = false
mode = "standalone"
bind_port = 9471
```
## Візуальні результати
![Візуальні результати Blackwall](assets/results-overview.svg)
-----
## Зв'язок із Cyberpunk
У всесвіті Cyberpunk 2077 **Blackwall** збудували після DataKrash 2022 року — коли вірус R.A.B.I.D.S. Рейчі Бартмосса знищив стару Мережу. NetWatch побудував Чорну Стіну як бар'єр, щоб стримати диких ШІ, що еволюціонували в руїнах.
Деякі персонажі — як-от Альт Каннінгем — існують за Чорною Стіною, перетворені на щось більше за людину, менше за живу істоту.
Цей проєкт бере цю концепцію і робить її реальною (ну, майже):
| Cyberpunk 2077 | The Blackwall (цей проєкт) |
|----------------|----------------------------|
| Чорна Стіна | eBPF/XDP файрвол на рівні ядра |
| ICE | XDP fast-path DROP + ентропія + JA4 |
| Атаки нетраннерів | Сканування портів, брутфорс, C2 beaconing |
| Демони за стіною | LLM-тарпіт, який прикидається справжнім сервером |
| Спостереження NetWatch | Поведінковий рушій + state machine на IP |
| Дикі ШІ | Ботнети та автоматичні сканери |
| Записи Брейндансу | PCAP-форензика |
| Розвідка фіксерів | Threat feeds (Firehol, abuse.ch) |
| C\&C Арасаки | Розподілений контролер |
-----
## Статистика проєкту
```
Мова: 100% Rust (без C, без Python, без shell-скриптів у продакшені)
Рядки коду: ~8 500
Файли: 48
Тести: 123
unwrap(): 0 (у продакшн-коді)
Залежності: 12 (затверджені, без зайвого)
eBPF стек: завжди ≤ 512 байт
Clippy: жодних попереджень (-D warnings)
```
-----
## Філософія розробки
> *"Скільки б разів я не бачив Найт-Сіті... він завжди перехоплює дух."*
1. **Жодних залежностей, де це можливо.** Якщо алгоритм займає менше 500 рядків — пишеш сам. Жодного `reqwest` (50+ транзитивних залежностей), жодного `clap` (зайве для 2 аргументів CLI).
2. **Контракт на першому місці.** Крейт `common` визначає всі спільні типи. eBPF та юзерспейс ніколи не сперечаються про структуру пам'яті.
3. **Жодних шорткатів в eBPF.** Кожен доступ `ctx.data()` має bounds check. Не тому що верифікатор вимагає, а тому що кожен байт із пакетів атакуючого — це ворожий вхід.
4. **Тарпіт ніколи не видає себе.** Системний промпт LLM ніколи не згадує "ханіпот". Prompt injection очікується і захищений.
5. **Спостережуваний, але не балакучий.** Структуроване tracing з рівнями. Жодних `println!` у продакшені.
-----
## Дисклеймер
Це дослідницький проєкт у сфері безпеки. Створений для вашої власної інфраструктури, в оборонних цілях. Не використовуйте для атак на інших. Не розгортайте тарпіт на продакшн-серверах, не розуміючи наслідків.
Я не афілійований із CD Projekt Red. Я просто зіграв у їхню гру, і вона зламала мені мозок у найкращий можливий спосіб.
-----
## Ліцензія
BSL 1.1 — бо Мережа потребує і свободи, і сталого розвитку.
-----
<p align="center">
<strong>Хочеш, щоб це розвивалось далі? — <a href="https://github.com/xzcrpw/blackwall">Постав зірку!</a></strong>
</p>
<p align="center">
<strong><em>"Прокинься, самураю. Нам ще мережу захищати."</em></strong>
</p>

99
assets/architecture.svg Executable file
View file

@ -0,0 +1,99 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="860" viewBox="0 0 1400 860" role="img" aria-label="Blackwall architecture diagram">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#060A1A"/>
<stop offset="100%" stop-color="#0E1328"/>
</linearGradient>
<linearGradient id="card" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111933"/>
<stop offset="100%" stop-color="#171F3E"/>
</linearGradient>
<style>
.title { fill:#F8FAFF; font:700 34px 'Segoe UI', Arial, sans-serif; }
.subtitle { fill:#9FB1DA; font:500 17px 'Segoe UI', Arial, sans-serif; }
.box { fill:url(#card); stroke:#2C3C72; stroke-width:2; rx:16; }
.hot { stroke:#FF4D4D; }
.txt { fill:#EAF0FF; font:600 19px 'Segoe UI', Arial, sans-serif; }
.small { fill:#AFC1E8; font:500 15px 'Segoe UI', Arial, sans-serif; }
.arrow { stroke:#6EC1FF; stroke-width:3; marker-end:url(#arrow); }
.arrow-hot { stroke:#FF6B6B; stroke-width:3; marker-end:url(#arrowHot); }
</style>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<polygon points="0,0 10,5 0,10" fill="#6EC1FF"/>
</marker>
<marker id="arrowHot" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<polygon points="0,0 10,5 0,10" fill="#FF6B6B"/>
</marker>
<marker id="arrowFeed" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<polygon points="0,0 10,5 0,10" fill="#4ADE80"/>
</marker>
<style>
.arrow-feed { stroke:#4ADE80; stroke-width:2.5; stroke-dasharray:8,4; marker-end:url(#arrowFeed); }
.feed-label { fill:#4ADE80; font:500 13px 'Segoe UI', Arial, sans-serif; }
</style>
</defs>
<rect width="1400" height="860" fill="url(#bg)"/>
<text x="70" y="70" class="title">The Blackwall - High-Level Architecture</text>
<text x="70" y="102" class="subtitle">Kernel fast path + behavioral engine + AI deception mesh</text>
<rect x="70" y="150" width="230" height="88" class="box"/>
<text x="95" y="186" class="txt">Internet Traffic</text>
<text x="95" y="212" class="small">Inbound + outbound packets</text>
<rect x="370" y="130" width="320" height="128" class="box hot"/>
<text x="395" y="175" class="txt">eBPF/XDP + TC Layer</text>
<text x="395" y="201" class="small">JA4, entropy, DPI tail-calls</text>
<text x="395" y="223" class="small">PASS / DROP / REDIRECT</text>
<rect x="770" y="150" width="260" height="88" class="box"/>
<text x="795" y="186" class="txt">RingBuf Events</text>
<text x="795" y="212" class="small">Zero-copy kernel telemetry</text>
<rect x="1110" y="130" width="220" height="128" class="box"/>
<text x="1135" y="175" class="txt">Threat Feeds</text>
<text x="1135" y="201" class="small">Firehol + abuse.ch</text>
<text x="1135" y="223" class="small">Hourly map updates</text>
<rect x="420" y="350" width="430" height="130" class="box hot"/>
<text x="445" y="398" class="txt">Behavioral Engine (userspace)</text>
<text x="445" y="424" class="small">Per-IP state machine, fast + AI verdicts</text>
<text x="445" y="446" class="small">New -> Suspicious -> Malicious -> Blocked</text>
<rect x="140" y="560" width="340" height="170" class="box"/>
<text x="165" y="603" class="txt">Deception Mesh / Tarpit</text>
<text x="165" y="629" class="small">SSH bash simulation</text>
<text x="165" y="651" class="small">HTTP fake admin + MySQL + DNS</text>
<text x="165" y="673" class="small">Prompt-injection defense</text>
<rect x="530" y="560" width="300" height="170" class="box"/>
<text x="555" y="603" class="txt">PCAP Capture</text>
<text x="555" y="629" class="small">Flagged IP traffic only</text>
<text x="555" y="651" class="small">Rotating compressed files</text>
<rect x="890" y="560" width="380" height="170" class="box"/>
<text x="915" y="603" class="txt">Distributed Controller</text>
<text x="915" y="629" class="small">Peer sync for blocked IPs + JA4</text>
<text x="915" y="651" class="small">One sensor learns, all nodes block</text>
<!-- Data flow: Internet → eBPF → RingBuf → Behavioral Engine -->
<line x1="300" y1="194" x2="370" y2="194" class="arrow"/>
<line x1="690" y1="194" x2="770" y2="194" class="arrow"/>
<line x1="900" y1="258" x2="720" y2="350" class="arrow"/>
<line x1="580" y1="258" x2="620" y2="350" class="arrow-hot"/>
<!-- Threat Feeds → Behavioral Engine (external intel) -->
<line x1="1220" y1="258" x2="850" y2="370" class="arrow-feed"/>
<text x="970" y="300" class="feed-label">intel updates</text>
<!-- Behavioral Engine → eBPF/XDP (BPF map updates) -->
<line x1="450" y1="350" x2="490" y2="258" class="arrow-feed"/>
<text x="400" y="310" class="feed-label">map sync</text>
<!-- Behavioral Engine → downstream modules -->
<line x1="560" y1="480" x2="310" y2="560" class="arrow-hot"/>
<line x1="640" y1="480" x2="680" y2="560" class="arrow"/>
<line x1="730" y1="480" x2="1020" y2="560" class="arrow"/>
<text x="70" y="810" class="subtitle">Rendered as SVG for crisp display on GitHub and dark/light themes.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

61
assets/results-overview.svg Executable file
View file

@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="760" viewBox="0 0 1400 760" role="img" aria-label="Blackwall result cards">
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#08101E"/>
<stop offset="100%" stop-color="#141B2F"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111A33"/>
<stop offset="100%" stop-color="#1B2442"/>
</linearGradient>
<style>
.h1 { fill:#F3F8FF; font:700 32px 'Segoe UI', Arial, sans-serif; }
.label { fill:#9FB5DD; font:600 16px 'Segoe UI', Arial, sans-serif; }
.term { fill:url(#panel); stroke:#334B84; stroke-width:2; rx:14; }
.mono { fill:#E9F1FF; font:600 16px 'JetBrains Mono', Consolas, monospace; }
.ok { fill:#60E7A7; }
.warn { fill:#FFC05A; }
.err { fill:#FF6E6E; }
.dot { rx:6; }
</style>
</defs>
<rect width="1400" height="760" fill="url(#bg2)"/>
<text x="70" y="68" class="h1">Blackwall - Visual Results</text>
<text x="70" y="98" class="label">Terminal-style snapshots (SVG) for README presentation</text>
<rect x="70" y="130" width="1260" height="260" class="term"/>
<rect x="96" y="152" width="12" height="12" class="dot err"/>
<rect x="116" y="152" width="12" height="12" class="dot warn"/>
<rect x="136" y="152" width="12" height="12" class="dot ok"/>
<text x="170" y="164" class="label">test + lint run</text>
<text x="100" y="205" class="mono">$ cargo clippy --workspace -- -D warnings</text>
<text x="100" y="236" class="mono ok">Finished dev [unoptimized + debuginfo] target(s) in 4.81s</text>
<text x="100" y="272" class="mono">$ cargo test --workspace</text>
<text x="100" y="303" class="mono ok">test result: ok. 123 passed; 0 failed; 0 ignored</text>
<text x="100" y="339" class="mono">$ cargo xtask build-ebpf</text>
<text x="100" y="370" class="mono ok">eBPF artifacts compiled successfully</text>
<rect x="70" y="430" width="610" height="260" class="term"/>
<rect x="96" y="452" width="12" height="12" class="dot err"/>
<rect x="116" y="452" width="12" height="12" class="dot warn"/>
<rect x="136" y="452" width="12" height="12" class="dot ok"/>
<text x="170" y="464" class="label">runtime status</text>
<text x="100" y="506" class="mono">[INFO] blackwall: attaching XDP program to eth0</text>
<text x="100" y="537" class="mono">[INFO] feeds: synced 2 feeds, 17,412 indicators</text>
<text x="100" y="568" class="mono">[INFO] behavior: suspicious ip=203.0.113.52 score=83</text>
<text x="100" y="599" class="mono ok">[INFO] action: redirected to tarpit</text>
<text x="100" y="630" class="mono">[INFO] pcap: capture started for flagged ip</text>
<rect x="720" y="430" width="610" height="260" class="term"/>
<rect x="746" y="452" width="12" height="12" class="dot err"/>
<rect x="766" y="452" width="12" height="12" class="dot warn"/>
<rect x="786" y="452" width="12" height="12" class="dot ok"/>
<text x="820" y="464" class="label">tarpit session snapshot</text>
<text x="750" y="506" class="mono">Ubuntu 24.04.2 LTS web-prod-03 tty1</text>
<text x="750" y="537" class="mono">root@web-prod-03:~# ls -la</text>
<text x="750" y="568" class="mono">drwxr-xr-x 2 root root 4096 Apr 01 12:31 .ssh</text>
<text x="750" y="599" class="mono">root@web-prod-03:~# cat /etc/passwd</text>
<text x="750" y="630" class="mono ok">[deception] full transcript stored</text>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

45
assets/signal-flow.svg Executable file
View file

@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="520" viewBox="0 0 1400 520" role="img" aria-label="Blackwall signal flow">
<defs>
<linearGradient id="bg3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0A1122"/>
<stop offset="100%" stop-color="#1B1730"/>
</linearGradient>
<style>
.box { fill:#131E3B; stroke:#3A4E82; stroke-width:2; rx:14; }
.txt { fill:#EEF3FF; font:600 18px 'Segoe UI', Arial, sans-serif; }
.sub { fill:#9FB1D6; font:500 14px 'Segoe UI', Arial, sans-serif; }
.title { fill:#F3F8FF; font:700 30px 'Segoe UI', Arial, sans-serif; }
.a { stroke:#74C0FF; stroke-width:3; marker-end:url(#m); }
</style>
<marker id="m" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<polygon points="0,0 10,5 0,10" fill="#74C0FF"/>
</marker>
</defs>
<rect width="1400" height="520" fill="url(#bg3)"/>
<text x="70" y="70" class="title">Threat Signal Flow</text>
<rect x="70" y="150" width="220" height="120" class="box"/>
<text x="95" y="198" class="txt">Packet Ingress</text>
<text x="95" y="224" class="sub">eth0 / xdp path</text>
<rect x="360" y="150" width="250" height="120" class="box"/>
<text x="385" y="198" class="txt">Kernel Detection</text>
<text x="385" y="224" class="sub">JA4 + DPI + entropy</text>
<rect x="680" y="150" width="250" height="120" class="box"/>
<text x="705" y="198" class="txt">Event Correlation</text>
<text x="705" y="224" class="sub">behavioral state machine</text>
<rect x="1000" y="80" width="300" height="120" class="box"/>
<text x="1025" y="128" class="txt">Mitigation Path</text>
<text x="1025" y="154" class="sub">drop / redirect / blocklist</text>
<rect x="1000" y="240" width="300" height="120" class="box"/>
<text x="1025" y="288" class="txt">Intelligence Path</text>
<text x="1025" y="314" class="sub">pcap + distributed sync</text>
<line x1="290" y1="210" x2="360" y2="210" class="a"/>
<line x1="610" y1="210" x2="680" y2="210" class="a"/>
<line x1="930" y1="190" x2="1000" y2="140" class="a"/>
<line x1="930" y1="230" x2="1000" y2="300" class="a"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

18
blackwall-controller/Cargo.toml Executable file
View file

@ -0,0 +1,18 @@
[package]
name = "blackwall-controller"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "blackwall-controller"
path = "src/main.rs"
[dependencies]
common = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ring = { workspace = true }

356
blackwall-controller/src/main.rs Executable file
View file

@ -0,0 +1,356 @@
//! Blackwall Controller — centralized monitoring for distributed Blackwall sensors.
//!
//! Connects to Blackwall sensor nodes via the peer protocol, collects
//! threat intelligence, and displays aggregated status on stdout.
use anyhow::{Context, Result};
use ring::hmac;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
/// Controller node ID prefix.
const CONTROLLER_ID: &str = "controller";
/// Default peer port for sensor connections.
const DEFAULT_PEER_PORT: u16 = 9471;
/// Status report interval.
const REPORT_INTERVAL: Duration = Duration::from_secs(10);
/// Connection timeout for reaching sensors.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Heartbeat interval.
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// Wire protocol constants (must match blackwall::distributed::proto).
const HELLO_TYPE: u8 = 0x01;
const HEARTBEAT_TYPE: u8 = 0x04;
const PROTOCOL_MAGIC: [u8; 4] = [0x42, 0x57, 0x4C, 0x01];
/// HMAC-SHA256 tag size.
const HMAC_SIZE: usize = 32;
/// V2 header: magic(4) + type(1) + payload_len(4) + hmac(32) = 41.
const HEADER_SIZE: usize = 4 + 1 + 4 + HMAC_SIZE;
/// State of a connected sensor.
struct SensorState {
addr: SocketAddr,
node_id: String,
last_seen: Instant,
blocked_ips: u32,
connected: bool,
stream: Option<TcpStream>,
}
/// Simple distributed controller that monitors Blackwall sensors.
struct Controller {
sensors: HashMap<SocketAddr, SensorState>,
node_id: String,
hmac_key: hmac::Key,
}
impl Controller {
fn new(psk: &[u8]) -> Self {
let hostname = std::env::var("HOSTNAME")
.unwrap_or_else(|_| "controller-0".into());
Self {
sensors: HashMap::new(),
node_id: format!("{}-{}", CONTROLLER_ID, hostname),
hmac_key: hmac::Key::new(hmac::HMAC_SHA256, psk),
}
}
/// Connect to a sensor at the given address.
async fn connect_sensor(&mut self, addr: SocketAddr) -> Result<()> {
let stream = tokio::time::timeout(
CONNECT_TIMEOUT,
TcpStream::connect(addr),
)
.await
.with_context(|| format!("timeout connecting to {}", addr))?
.with_context(|| format!("failed to connect to {}", addr))?;
// Send HELLO with V2 wire protocol (magic + type + len + hmac + JSON payload)
let hello = encode_hello(&self.node_id, &self.hmac_key);
let mut stream = stream;
stream.write_all(&hello).await
.with_context(|| format!("failed to send hello to {}", addr))?;
// Try to read a framed response (non-blocking with short timeout)
let mut node_id = format!("sensor-{}", addr);
let mut blocked_count = 0u32;
let mut authenticated = false;
match tokio::time::timeout(
Duration::from_secs(2),
read_frame(&mut stream, &self.hmac_key),
).await {
Ok(Ok((msg_type, payload))) => {
if msg_type == HELLO_TYPE {
if let Ok(hello_resp) = serde_json::from_slice::<HelloResponse>(&payload) {
node_id = hello_resp.node_id;
blocked_count = hello_resp.blocked_count;
authenticated = true;
}
}
}
Ok(Err(e)) => {
tracing::warn!(%addr, error = %e, "sensor authentication failed");
}
Err(_) => {
tracing::warn!(%addr, "sensor HELLO response timeout — not authenticated");
}
}
if !authenticated {
tracing::warn!(%addr, "sensor NOT connected — HMAC authentication failed");
self.sensors.insert(addr, SensorState {
addr,
node_id,
last_seen: Instant::now(),
blocked_ips: 0,
connected: false,
stream: None,
});
return Ok(());
}
tracing::info!(%addr, %node_id, blocked_count, "sensor connected");
self.sensors.insert(addr, SensorState {
addr,
node_id,
last_seen: Instant::now(),
blocked_ips: blocked_count,
connected: true,
stream: Some(stream),
});
Ok(())
}
/// Send heartbeat to all connected sensors and read responses.
async fn send_heartbeats(&mut self) {
let heartbeat_msg = encode_heartbeat(&self.hmac_key);
for sensor in self.sensors.values_mut() {
if !sensor.connected {
continue;
}
let stream = match sensor.stream.as_mut() {
Some(s) => s,
None => {
sensor.connected = false;
continue;
}
};
// Send heartbeat
if stream.write_all(&heartbeat_msg).await.is_err() {
tracing::debug!(addr = %sensor.addr, "heartbeat send failed — marking offline");
sensor.connected = false;
sensor.stream = None;
continue;
}
// Try to read a response (non-blocking, short timeout)
match tokio::time::timeout(Duration::from_secs(2), read_frame(stream, &self.hmac_key)).await {
Ok(Ok((msg_type, payload))) => {
sensor.last_seen = Instant::now();
if msg_type == HELLO_TYPE {
if let Ok(resp) = serde_json::from_slice::<HelloResponse>(&payload) {
sensor.blocked_ips = resp.blocked_count;
}
}
}
Ok(Err(e)) => {
tracing::warn!(addr = %sensor.addr, error = %e, "heartbeat HMAC error — disconnecting");
sensor.connected = false;
sensor.stream = None;
}
Err(_) => {
// Timeout reading response — don't update last_seen,
// sensor may be unreachable
tracing::debug!(addr = %sensor.addr, "heartbeat response timeout");
}
}
}
}
/// Print a status report of all sensors.
fn print_status(&self) {
println!("\n=== Blackwall Controller Status ===");
println!("Sensors: {}", self.sensors.len());
println!("{:<25} {:<20} {:<12} {:<10}", "Address", "Node ID", "Blocked IPs", "Status");
println!("{}", "-".repeat(70));
for sensor in self.sensors.values() {
let age = sensor.last_seen.elapsed().as_secs();
let status = if sensor.connected && age < 60 {
"online"
} else {
"stale"
};
println!(
"{:<25} {:<20} {:<12} {:<10}",
sensor.addr,
&sensor.node_id[..sensor.node_id.len().min(19)],
sensor.blocked_ips,
status,
);
}
println!();
}
}
/// Encode a HELLO message with V2 wire protocol:
/// magic(4) + type(1) + payload_len(4) + hmac(32) + JSON payload.
fn encode_hello(node_id: &str, key: &hmac::Key) -> Vec<u8> {
let payload = format!(
r#"{{"node_id":"{}","version":"1.0.0","blocked_count":0}}"#,
node_id
);
let payload_bytes = payload.as_bytes();
encode_message(HELLO_TYPE, payload_bytes, key)
}
/// Encode a heartbeat message (empty payload) with HMAC.
fn encode_heartbeat(key: &hmac::Key) -> Vec<u8> {
encode_message(HEARTBEAT_TYPE, &[], key)
}
/// Encode a V2 wire message: magic(4) + type(1) + payload_len(4) + hmac(32) + payload.
/// HMAC covers: magic + type + payload_len + payload.
fn encode_message(msg_type: u8, payload: &[u8], key: &hmac::Key) -> Vec<u8> {
let len = payload.len() as u32;
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&PROTOCOL_MAGIC);
buf.push(msg_type);
buf.extend_from_slice(&len.to_le_bytes());
// Compute HMAC over header fields + payload
let mut ctx = hmac::Context::with_key(key);
ctx.update(&PROTOCOL_MAGIC);
ctx.update(&[msg_type]);
ctx.update(&len.to_le_bytes());
ctx.update(payload);
let tag = ctx.sign();
buf.extend_from_slice(tag.as_ref());
buf.extend_from_slice(payload);
buf
}
/// Read a single V2 framed message from a stream. Returns (type_byte, payload).
/// Verifies HMAC-SHA256 and rejects unauthenticated messages.
async fn read_frame(stream: &mut TcpStream, key: &hmac::Key) -> Result<(u8, Vec<u8>)> {
let mut header = [0u8; HEADER_SIZE];
stream.read_exact(&mut header).await.context("read header")?;
if header[..4] != PROTOCOL_MAGIC {
anyhow::bail!("bad magic");
}
let msg_type = header[4];
let payload_len = u32::from_le_bytes([header[5], header[6], header[7], header[8]]) as usize;
if payload_len > 65536 {
anyhow::bail!("payload too large");
}
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
stream.read_exact(&mut payload).await.context("read payload")?;
}
// Verify HMAC: tag is at header[9..41], signed data = magic+type+len+payload
let hmac_tag = &header[9..9 + HMAC_SIZE];
let mut verify_data = Vec::with_capacity(9 + payload.len());
verify_data.extend_from_slice(&header[..9]);
verify_data.extend_from_slice(&payload);
hmac::verify(key, &verify_data, hmac_tag)
.map_err(|_| anyhow::anyhow!("HMAC verification failed — wrong PSK or tampered response"))?;
Ok((msg_type, payload))
}
/// Deserialized HELLO response from sensor.
#[derive(serde::Deserialize)]
struct HelloResponse {
#[serde(default)]
node_id: String,
#[serde(default)]
blocked_count: u32,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("blackwall_controller=info")),
)
.init();
tracing::info!("Blackwall Controller starting");
// PSK for HMAC-SHA256 peer authentication (must match sensor config)
let psk = std::env::var("BLACKWALL_PSK")
.unwrap_or_default();
if psk.is_empty() {
anyhow::bail!(
"BLACKWALL_PSK environment variable is required. \
Set it to the same peer_psk value configured on your sensors."
);
}
// Parse sensor addresses from args: blackwall-controller <addr1> <addr2> ...
let sensor_addrs: Vec<SocketAddr> = std::env::args()
.skip(1)
.filter_map(|arg| {
// Accept "host:port" or just "host" (use default port)
if arg.contains(':') {
arg.parse().ok()
} else {
format!("{}:{}", arg, DEFAULT_PEER_PORT).parse().ok()
}
})
.collect();
if sensor_addrs.is_empty() {
tracing::info!("usage: BLACKWALL_PSK=<key> blackwall-controller <addr:port> [...]");
tracing::info!("example: BLACKWALL_PSK=mysecret blackwall-controller 192.168.1.10:9471");
return Ok(());
}
let mut controller = Controller::new(psk.as_bytes());
tracing::info!(node_id = %controller.node_id, sensors = sensor_addrs.len(), "connecting to sensors");
// Initial connection to all sensors
for addr in &sensor_addrs {
if let Err(e) = controller.connect_sensor(*addr).await {
tracing::warn!(%addr, "failed to connect to sensor: {}", e);
}
}
controller.print_status();
// Main loop: periodic status reports + reconnection
let mut report_interval = tokio::time::interval(REPORT_INTERVAL);
let mut heartbeat_interval = tokio::time::interval(HEARTBEAT_INTERVAL);
loop {
tokio::select! {
_ = report_interval.tick() => {
controller.print_status();
}
_ = heartbeat_interval.tick() => {
// Send heartbeats to connected sensors and read responses
controller.send_heartbeats().await;
// Reconnect disconnected sensors
for addr in &sensor_addrs {
let is_disconnected = controller.sensors
.get(addr)
.map(|s| !s.connected)
.unwrap_or(true);
if is_disconnected {
if let Err(e) = controller.connect_sensor(*addr).await {
tracing::debug!(%addr, "reconnect failed: {}", e);
}
}
}
}
_ = tokio::signal::ctrl_c() => {
tracing::info!("shutting down");
break;
}
}
}
Ok(())
}

257
blackwall-ebpf/Cargo.lock generated Executable file
View file

@ -0,0 +1,257 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "aya-build"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59bc42f3c5ddacc34eca28a420b47e3cbb3f0f484137cb2bf1ad2153d0eae52a"
dependencies = [
"anyhow",
"cargo_metadata",
]
[[package]]
name = "aya-ebpf"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8dbaf5409a1a0982e5c9bdc0f499a55fe5ead39fe9c846012053faf0d404f73"
dependencies = [
"aya-ebpf-bindings",
"aya-ebpf-cty",
"aya-ebpf-macros",
"rustversion",
]
[[package]]
name = "aya-ebpf-bindings"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ee8e6a617f040d8da7565ec4010aea75e33cda4662f64c019c66ee97d17889"
dependencies = [
"aya-build",
"aya-ebpf-cty",
]
[[package]]
name = "aya-ebpf-cty"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6f33396742e7fd0f519c1e0de5141d84e1a8df69146a557c08cc222b0ceace4"
dependencies = [
"aya-build",
]
[[package]]
name = "aya-ebpf-macros"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96fd02363736177e7e91d6c95d7effbca07be87502c7b5b32fc194aed8b177a0"
dependencies = [
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn",
]
[[package]]
name = "blackwall-ebpf"
version = "0.1.0"
dependencies = [
"aya-ebpf",
"common",
]
[[package]]
name = "camino"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "cargo_metadata"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "common"
version = "0.1.0"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"version_check",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

21
blackwall-ebpf/Cargo.toml Executable file
View file

@ -0,0 +1,21 @@
[package]
name = "blackwall-ebpf"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "blackwall-ebpf"
path = "src/main.rs"
[dependencies]
common = { path = "../common", default-features = false }
aya-ebpf = "0.1"
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
opt-level = 2
strip = "none"
debug = 2

View file

@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["rust-src"]

1334
blackwall-ebpf/src/main.rs Executable file

File diff suppressed because it is too large Load diff

33
blackwall/Cargo.toml Executable file
View file

@ -0,0 +1,33 @@
[package]
name = "blackwall"
version = "0.1.0"
edition = "2021"
[features]
default = []
# Legacy iptables DNAT fallback. V2.0 uses eBPF native DNAT exclusively.
iptables-legacy = []
[[bin]]
name = "blackwall"
path = "src/main.rs"
[dependencies]
common = { workspace = true }
aya = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
papaya = { workspace = true }
crossbeam-queue = { workspace = true }
nix = { workspace = true }
rand = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
hyper-rustls = { workspace = true }
http-body-util = { workspace = true }
ring = { workspace = true }

74
blackwall/src/ai/batch.rs Executable file
View file

@ -0,0 +1,74 @@
use common::PacketEvent;
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Batches PacketEvents by source IP, with time-window flushing.
pub struct EventBatcher {
/// Events grouped by src_ip, with timestamp of first event.
pending: HashMap<u32, BatchEntry>,
/// Max events per batch before forced flush.
max_batch_size: usize,
/// Time window before auto-flush.
window_duration: Duration,
}
struct BatchEntry {
events: Vec<PacketEvent>,
first_seen: Instant,
}
impl EventBatcher {
/// Create a new batcher with given limits.
pub fn new(max_batch_size: usize, window_secs: u64) -> Self {
Self {
pending: HashMap::new(),
max_batch_size,
window_duration: Duration::from_secs(window_secs),
}
}
/// Add an event to the batch. Returns `Some(batch)` if the batch is ready
/// for classification (hit max size).
pub fn push(&mut self, event: PacketEvent) -> Option<Vec<PacketEvent>> {
let ip = event.src_ip;
let entry = self.pending.entry(ip).or_insert_with(|| BatchEntry {
events: Vec::new(),
first_seen: Instant::now(),
});
entry.events.push(event);
if entry.events.len() >= self.max_batch_size {
let batch = self.pending.remove(&ip).map(|e| e.events);
return batch;
}
None
}
/// Discard any pending batch for a given IP (e.g., after blocking it).
pub fn discard_ip(&mut self, ip: u32) {
self.pending.remove(&ip);
}
/// Flush all batches older than the window duration.
pub fn flush_expired(&mut self) -> Vec<(u32, Vec<PacketEvent>)> {
let now = Instant::now();
let mut flushed = Vec::new();
let mut expired_keys = Vec::new();
for (&ip, entry) in &self.pending {
if now.duration_since(entry.first_seen) >= self.window_duration {
expired_keys.push(ip);
}
}
for ip in expired_keys {
if let Some(entry) = self.pending.remove(&ip) {
flushed.push((ip, entry.events));
}
}
flushed
}
}

365
blackwall/src/ai/classifier.rs Executable file
View file

@ -0,0 +1,365 @@
use common::PacketEvent;
use std::collections::HashSet;
use crate::ai::client::OllamaClient;
/// System prompt for threat classification. Low temperature, structured output.
pub const CLASSIFICATION_SYSTEM_PROMPT: &str = r#"You are a network security analyst.
Analyze the following traffic summary and classify the threat.
Respond with EXACTLY one line in this format:
VERDICT:<Benign|Suspicious|Malicious> CATEGORY:<category> CONFIDENCE:<0.0-1.0>
Categories: DDoS_SYN_Flood, DDoS_UDP_Flood, Port_Scan, Brute_Force,
Exploit, C2_Communication, Data_Exfiltration, Other
Example: VERDICT:Malicious CATEGORY:Port_Scan CONFIDENCE:0.85"#;
/// Classification verdict from the AI module.
#[derive(Debug, Clone, PartialEq)]
pub enum ThreatVerdict {
/// Normal traffic.
Benign,
/// Needs monitoring but not yet actionable.
Suspicious { reason: String, confidence: f32 },
/// Confirmed threat — take action.
Malicious {
category: ThreatCategory,
confidence: f32,
},
/// LLM unavailable, deterministic rules didn't match.
Unknown,
}
/// Threat categories for classification.
#[derive(Debug, Clone, PartialEq)]
pub enum ThreatCategory {
DdosSynFlood,
DdosUdpFlood,
PortScan,
BruteForce,
Exploit,
C2Communication,
DataExfiltration,
Other(String),
}
/// Classifies batched events using deterministic rules + LLM fallback.
pub struct ThreatClassifier {
client: OllamaClient,
}
impl ThreatClassifier {
/// Create a new classifier backed by the given Ollama client.
pub fn new(client: OllamaClient) -> Self {
Self { client }
}
/// Get a reference to the underlying Ollama client.
pub fn client(&self) -> &OllamaClient {
&self.client
}
/// Classify a batch of events from the same source IP.
pub async fn classify(&self, events: &[PacketEvent]) -> ThreatVerdict {
if events.is_empty() {
return ThreatVerdict::Benign;
}
// 1. Quick deterministic check
if let Some(verdict) = self.deterministic_classify(events) {
return verdict;
}
// 2. If LLM unavailable, return Unknown
if !self.client.is_available() {
return ThreatVerdict::Unknown;
}
// 3. Build prompt and query LLM
let prompt = self.build_classification_prompt(events);
match self.client.classify_threat(&prompt).await {
Ok(response) => self.parse_llm_response(&response),
Err(_) => ThreatVerdict::Unknown,
}
}
/// Deterministic fallback classification (no LLM needed).
fn deterministic_classify(&self, events: &[PacketEvent]) -> Option<ThreatVerdict> {
let count = events.len() as u32;
if count == 0 {
return None;
}
let avg_entropy = events.iter().map(|e| e.entropy_score).sum::<u32>() / count;
// Very high entropy + many events → encrypted attack payload
if avg_entropy > 7500 && events.len() > 50 {
return Some(ThreatVerdict::Malicious {
category: ThreatCategory::Exploit,
confidence: 0.7,
});
}
// SYN flood detection: many SYN without ACK
let syn_count = events
.iter()
.filter(|e| e.flags & 0x02 != 0 && e.flags & 0x10 == 0)
.count();
if syn_count > 100 {
return Some(ThreatVerdict::Malicious {
category: ThreatCategory::DdosSynFlood,
confidence: 0.9,
});
}
// Port scan detection: many unique destination ports
let unique_ports: HashSet<u16> = events.iter().map(|e| e.dst_port).collect();
if unique_ports.len() > 20 {
return Some(ThreatVerdict::Malicious {
category: ThreatCategory::PortScan,
confidence: 0.85,
});
}
None
}
fn build_classification_prompt(&self, events: &[PacketEvent]) -> String {
let src_ip = common::util::ip_from_u32(events[0].src_ip);
let event_count = events.len();
let avg_entropy = events.iter().map(|e| e.entropy_score).sum::<u32>() / event_count as u32;
let unique_dst_ports: HashSet<u16> = events.iter().map(|e| e.dst_port).collect();
let syn_count = events.iter().filter(|e| e.flags & 0x02 != 0).count();
let ack_count = events.iter().filter(|e| e.flags & 0x10 != 0).count();
let rst_count = events.iter().filter(|e| e.flags & 0x04 != 0).count();
let tcp_count = events.iter().filter(|e| e.protocol == 6).count();
let udp_count = events.iter().filter(|e| e.protocol == 17).count();
format!(
"Source IP: {}\nEvent count: {} in last 10s\n\
Avg byte diversity: {:.1}/8.0\nProtocols: TCP={}, UDP={}\n\
Unique dst ports: {}\n\
TCP flags: SYN={}, ACK={}, RST={}",
src_ip,
event_count,
avg_entropy as f64 / 1000.0,
tcp_count,
udp_count,
unique_dst_ports.len(),
syn_count,
ack_count,
rst_count,
)
}
fn parse_llm_response(&self, response: &str) -> ThreatVerdict {
// Parse format: "VERDICT:Malicious CATEGORY:Port_Scan CONFIDENCE:0.85"
let line = response.lines().find(|l| l.starts_with("VERDICT:"));
let line = match line {
Some(l) => l,
None => return ThreatVerdict::Unknown,
};
let mut verdict_str = "";
let mut category_str = "";
let mut confidence: f32 = 0.0;
for part in line.split_whitespace() {
if let Some(v) = part.strip_prefix("VERDICT:") {
verdict_str = v;
} else if let Some(c) = part.strip_prefix("CATEGORY:") {
category_str = c;
} else if let Some(conf) = part.strip_prefix("CONFIDENCE:") {
confidence = conf.parse().unwrap_or(0.0);
}
}
match verdict_str {
"Benign" => ThreatVerdict::Benign,
"Suspicious" => ThreatVerdict::Suspicious {
reason: category_str.to_string(),
confidence,
},
"Malicious" => {
let category = match category_str {
"DDoS_SYN_Flood" => ThreatCategory::DdosSynFlood,
"DDoS_UDP_Flood" => ThreatCategory::DdosUdpFlood,
"Port_Scan" => ThreatCategory::PortScan,
"Brute_Force" => ThreatCategory::BruteForce,
"Exploit" => ThreatCategory::Exploit,
"C2_Communication" => ThreatCategory::C2Communication,
"Data_Exfiltration" => ThreatCategory::DataExfiltration,
other => ThreatCategory::Other(other.to_string()),
};
ThreatVerdict::Malicious {
category,
confidence,
}
}
_ => ThreatVerdict::Unknown,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::client::OllamaClient;
fn test_classifier() -> ThreatClassifier {
let client = OllamaClient::new(
"http://localhost:11434".into(),
"test".into(),
"test".into(),
1000,
);
ThreatClassifier::new(client)
}
#[test]
fn parse_malicious_verdict() {
let c = test_classifier();
let resp = "VERDICT:Malicious CATEGORY:Port_Scan CONFIDENCE:0.85";
match c.parse_llm_response(resp) {
ThreatVerdict::Malicious {
category,
confidence,
} => {
assert_eq!(category, ThreatCategory::PortScan);
assert!((confidence - 0.85).abs() < 0.01);
}
other => panic!("expected Malicious, got {:?}", other),
}
}
#[test]
fn parse_benign_verdict() {
let c = test_classifier();
let resp = "VERDICT:Benign CATEGORY:None CONFIDENCE:0.95";
assert_eq!(c.parse_llm_response(resp), ThreatVerdict::Benign);
}
#[test]
fn parse_suspicious_verdict() {
let c = test_classifier();
let resp = "VERDICT:Suspicious CATEGORY:Brute_Force CONFIDENCE:0.6";
match c.parse_llm_response(resp) {
ThreatVerdict::Suspicious { reason, confidence } => {
assert_eq!(reason, "Brute_Force");
assert!((confidence - 0.6).abs() < 0.01);
}
other => panic!("expected Suspicious, got {:?}", other),
}
}
#[test]
fn parse_unknown_on_garbage() {
let c = test_classifier();
assert_eq!(
c.parse_llm_response("some random LLM output"),
ThreatVerdict::Unknown
);
}
#[test]
fn parse_unknown_on_empty() {
let c = test_classifier();
assert_eq!(c.parse_llm_response(""), ThreatVerdict::Unknown);
}
#[test]
fn parse_multiline_finds_verdict() {
let c = test_classifier();
let resp =
"Analyzing traffic...\nVERDICT:Malicious CATEGORY:DDoS_SYN_Flood CONFIDENCE:0.9\nDone.";
match c.parse_llm_response(resp) {
ThreatVerdict::Malicious { category, .. } => {
assert_eq!(category, ThreatCategory::DdosSynFlood);
}
other => panic!("expected Malicious, got {:?}", other),
}
}
#[test]
fn deterministic_syn_flood_detection() {
let c = test_classifier();
// Generate 120 SYN-only events (flags = 0x02)
let events: Vec<PacketEvent> = (0..120)
.map(|_| PacketEvent {
src_ip: 0x0A000001,
dst_ip: 0x0A000002,
src_port: 12345,
dst_port: 80,
protocol: 6,
flags: 0x02, // SYN only
payload_len: 0,
entropy_score: 1000,
timestamp_ns: 0,
_padding: 0,
packet_size: 64,
})
.collect();
match c.deterministic_classify(&events) {
Some(ThreatVerdict::Malicious { category, .. }) => {
assert_eq!(category, ThreatCategory::DdosSynFlood);
}
other => panic!("expected SYN flood, got {:?}", other),
}
}
#[test]
fn deterministic_port_scan_detection() {
let c = test_classifier();
// Generate events to 25 unique destination ports
let events: Vec<PacketEvent> = (0..25)
.map(|i| PacketEvent {
src_ip: 0x0A000001,
dst_ip: 0x0A000002,
src_port: 12345,
dst_port: 1000 + i as u16,
protocol: 6,
flags: 0x02,
payload_len: 64,
entropy_score: 3000,
timestamp_ns: 0,
_padding: 0,
packet_size: 128,
})
.collect();
match c.deterministic_classify(&events) {
Some(ThreatVerdict::Malicious { category, .. }) => {
assert_eq!(category, ThreatCategory::PortScan);
}
other => panic!("expected PortScan, got {:?}", other),
}
}
#[test]
fn deterministic_benign_traffic() {
let c = test_classifier();
// Few events, normal entropy, single port
let events: Vec<PacketEvent> = (0..5)
.map(|_| PacketEvent {
src_ip: 0x0A000001,
dst_ip: 0x0A000002,
src_port: 12345,
dst_port: 443,
protocol: 6,
flags: 0x12, // SYN+ACK
payload_len: 64,
entropy_score: 3000,
timestamp_ns: 0,
_padding: 0,
packet_size: 128,
})
.collect();
// Should return None (no deterministic match → falls through to LLM)
assert!(c.deterministic_classify(&events).is_none());
}
}

151
blackwall/src/ai/client.rs Executable file
View file

@ -0,0 +1,151 @@
use anyhow::{Context, Result};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::Request;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
/// Max concurrent LLM requests. Prevents queue buildup under DDoS when many
/// unique IPs generate batches faster than Ollama can classify them.
/// Excess requests are shed (return Unknown verdict) rather than queued.
const MAX_CONCURRENT_LLM_REQUESTS: u32 = 2;
/// HTTP client for the Ollama REST API with backpressure.
pub struct OllamaClient {
base_url: String,
model: String,
fallback_model: String,
timeout: Duration,
available: AtomicBool,
/// Tracks in-flight LLM requests for backpressure.
in_flight: AtomicU32,
/// Counter of requests shed due to backpressure.
shed_count: AtomicU32,
}
impl OllamaClient {
/// Create a new client from AI config values.
pub fn new(base_url: String, model: String, fallback_model: String, timeout_ms: u64) -> Self {
Self {
base_url,
model,
fallback_model,
timeout: Duration::from_millis(timeout_ms),
available: AtomicBool::new(false),
in_flight: AtomicU32::new(0),
shed_count: AtomicU32::new(0),
}
}
/// Number of requests shed due to backpressure since start.
#[allow(dead_code)]
pub fn shed_count(&self) -> u32 {
self.shed_count.load(Ordering::Relaxed)
}
/// Check if Ollama is reachable (GET /api/tags).
pub async fn health_check(&self) -> bool {
let client = Client::builder(TokioExecutor::new()).build_http();
let url = format!("{}/api/tags", self.base_url);
let req = match Request::get(&url).body(http_body_util::Empty::<Bytes>::new()) {
Ok(r) => r,
Err(_) => return false,
};
let result = tokio::time::timeout(Duration::from_secs(3), client.request(req)).await;
let ok = matches!(result, Ok(Ok(resp)) if resp.status().is_success());
self.available.store(ok, Ordering::Relaxed);
ok
}
/// Whether the last health check succeeded.
pub fn is_available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
/// Send a classification prompt to the LLM. Tries primary, then fallback.
///
/// Applies backpressure: if `MAX_CONCURRENT_LLM_REQUESTS` are already
/// in-flight, returns an error immediately (load shedding).
pub async fn classify_threat(&self, prompt: &str) -> Result<String> {
// Backpressure: reject if too many in-flight requests
let current = self.in_flight.fetch_add(1, Ordering::Relaxed);
if current >= MAX_CONCURRENT_LLM_REQUESTS {
self.in_flight.fetch_sub(1, Ordering::Relaxed);
let shed = self.shed_count.fetch_add(1, Ordering::Relaxed) + 1;
tracing::debug!(in_flight = current, total_shed = shed,
"LLM backpressure — request shed");
anyhow::bail!("LLM backpressure: {} in-flight, request shed", current);
}
let result = self.classify_inner(prompt).await;
self.in_flight.fetch_sub(1, Ordering::Relaxed);
result
}
/// Inner classification logic (primary + fallback).
async fn classify_inner(&self, prompt: &str) -> Result<String> {
let body = self.build_body(prompt, &self.model)?;
match self.send(&body).await {
Ok(r) => Ok(r),
Err(e) => {
tracing::warn!("primary model failed: {}, trying fallback", e);
let fallback_body = self.build_body(prompt, &self.fallback_model)?;
self.send(&fallback_body).await
}
}
}
fn build_body(&self, prompt: &str, model: &str) -> Result<Vec<u8>> {
let body = serde_json::json!({
"model": model,
"messages": [
{"role": "system", "content": super::classifier::CLASSIFICATION_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"stream": false,
"options": {
"num_predict": 256,
"temperature": 0.1,
},
});
serde_json::to_vec(&body).context("serialize request")
}
async fn send(&self, body: &[u8]) -> Result<String> {
let client = Client::builder(TokioExecutor::new()).build_http();
let req = Request::post(format!("{}/api/chat", self.base_url))
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(body.to_vec())))
.context("build request")?;
// Wrap BOTH the request send AND body read in a single timeout.
// Without this, a slowloris-style response from Ollama (infinitely
// slow body) hangs forever, in_flight never decrements, and after
// MAX_CONCURRENT_LLM_REQUESTS such requests the AI pipeline is dead.
let bytes = tokio::time::timeout(self.timeout, async {
let resp = client
.request(req)
.await
.context("HTTP request failed")?;
let collected = resp
.into_body()
.collect()
.await
.context("read response body")?
.to_bytes();
Ok::<_, anyhow::Error>(collected)
})
.await
.context("LLM request+response timed out")??;
let json: serde_json::Value = serde_json::from_slice(&bytes).context("invalid JSON")?;
json["message"]["content"]
.as_str()
.map(|s| s.to_string())
.context("missing content in response")
}
}

3
blackwall/src/ai/mod.rs Executable file
View file

@ -0,0 +1,3 @@
pub mod batch;
pub mod classifier;
pub mod client;

159
blackwall/src/antifingerprint.rs Executable file
View file

@ -0,0 +1,159 @@
//! Anti-fingerprinting: evade attacker reconnaissance.
//!
//! Randomizes observable characteristics to prevent attackers from
//! identifying Blackwall's presence through response timing, error
//! messages, or behavior patterns.
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::time::Duration;
/// Jitter range for response timing (ms).
const MIN_JITTER_MS: u64 = 10;
const MAX_JITTER_MS: u64 = 500;
/// Pool of fake server banners for HTTP responses.
const HTTP_SERVER_BANNERS: &[&str] = &[
"Apache/2.4.58 (Ubuntu)",
"Apache/2.4.57 (Debian)",
"nginx/1.24.0",
"nginx/1.26.0 (Ubuntu)",
"Microsoft-IIS/10.0",
"LiteSpeed",
"openresty/1.25.3.1",
"Caddy",
];
/// Pool of fake SSH banners.
const SSH_BANNERS: &[&str] = &[
"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5",
"SSH-2.0-OpenSSH_9.7p1 Debian-5",
"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10",
"SSH-2.0-OpenSSH_9.3p1 Ubuntu-1ubuntu3.6",
"SSH-2.0-dropbear_2022.82",
];
/// Pool of fake MySQL version strings.
const MYSQL_VERSIONS: &[&str] = &[
"8.0.36-0ubuntu0.24.04.1",
"8.0.35-0ubuntu0.22.04.1",
"8.0.37",
"5.7.44-log",
"10.11.6-MariaDB",
];
/// Pool of fake operating system identifiers (for SSH comments).
const OS_COMMENTS: &[&str] = &[
"Ubuntu-3ubuntu13.5",
"Debian-5+deb12u1",
"Ubuntu-1ubuntu3.6",
"FreeBSD-20240806",
];
/// Anti-fingerprinting profile: randomized per-session.
pub struct AntiFingerprintProfile {
rng: StdRng,
/// Selected HTTP server banner for this session
pub http_banner: &'static str,
/// Selected SSH banner for this session
pub ssh_banner: &'static str,
/// Selected MySQL version for this session
pub mysql_version: &'static str,
/// Selected OS comment for this session
pub os_comment: &'static str,
}
impl AntiFingerprintProfile {
/// Create a new randomized profile.
pub fn new() -> Self {
let mut rng = StdRng::from_entropy();
let http_banner = HTTP_SERVER_BANNERS[rng.gen_range(0..HTTP_SERVER_BANNERS.len())];
let ssh_banner = SSH_BANNERS[rng.gen_range(0..SSH_BANNERS.len())];
let mysql_version = MYSQL_VERSIONS[rng.gen_range(0..MYSQL_VERSIONS.len())];
let os_comment = OS_COMMENTS[rng.gen_range(0..OS_COMMENTS.len())];
Self {
rng,
http_banner,
ssh_banner,
mysql_version,
os_comment,
}
}
/// Generate a random delay to add to a response (anti-timing-analysis).
pub fn response_jitter(&mut self) -> Duration {
Duration::from_millis(self.rng.gen_range(MIN_JITTER_MS..=MAX_JITTER_MS))
}
/// Randomly decide whether to add a fake header to an HTTP response.
pub fn should_add_fake_header(&mut self) -> bool {
self.rng.gen_ratio(1, 3) // 33% chance
}
/// Generate a random fake HTTP header.
pub fn fake_http_header(&mut self) -> (&'static str, String) {
let headers = [
("X-Powered-By", vec!["PHP/8.3.6", "PHP/8.2.18", "ASP.NET", "Express"]),
("X-Cache", vec!["HIT", "MISS", "HIT from cdn-edge-01"]),
("Via", vec!["1.1 varnish", "1.1 squid", "HTTP/1.1 cloudfront"]),
];
let (name, values) = &headers[self.rng.gen_range(0..headers.len())];
let value = values[self.rng.gen_range(0..values.len())].to_string();
(name, value)
}
/// Randomly corrupt a timestamp to prevent timing attacks.
pub fn fuzz_timestamp(&mut self, base_secs: u64) -> u64 {
let drift = self.rng.gen_range(0..=5);
base_secs.wrapping_add(drift)
}
}
impl Default for AntiFingerprintProfile {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_randomization() {
let p1 = AntiFingerprintProfile::new();
// Just verify it doesn't panic and produces valid strings
assert!(!p1.http_banner.is_empty());
assert!(p1.ssh_banner.starts_with("SSH-2.0-"));
assert!(!p1.mysql_version.is_empty());
assert!(!p1.os_comment.is_empty());
}
#[test]
fn jitter_in_range() {
let mut profile = AntiFingerprintProfile::new();
for _ in 0..100 {
let jitter = profile.response_jitter();
assert!(jitter.as_millis() >= MIN_JITTER_MS as u128);
assert!(jitter.as_millis() <= MAX_JITTER_MS as u128);
}
}
#[test]
fn fake_header_generation() {
let mut profile = AntiFingerprintProfile::new();
let (name, value) = profile.fake_http_header();
assert!(!name.is_empty());
assert!(!value.is_empty());
}
#[test]
fn timestamp_fuzzing() {
let mut profile = AntiFingerprintProfile::new();
let base = 1000u64;
let fuzzed = profile.fuzz_timestamp(base);
assert!(fuzzed >= base);
assert!(fuzzed <= base + 5);
}
}

12
blackwall/src/behavior/mod.rs Executable file
View file

@ -0,0 +1,12 @@
//! Behavioral engine: per-IP state machine for threat progression tracking.
//!
//! Each source IP gets a `BehaviorProfile` that tracks packet statistics,
//! connection patterns, and a phase in the threat lifecycle. Transitions
//! are driven by deterministic thresholds (fast path) with optional
//! LLM-assisted classification (slow path).
mod profile;
mod transitions;
pub use profile::{BehaviorPhase, BehaviorProfile};
pub use transitions::{TransitionVerdict, evaluate_transitions};

430
blackwall/src/behavior/profile.rs Executable file
View file

@ -0,0 +1,430 @@
//! Per-IP behavioral profile: statistics and lifecycle phase tracking.
use std::collections::HashSet;
use std::time::Instant;
/// Lifecycle phases for a tracked IP address.
/// Transitions are monotonically increasing in suspicion (except demotion to Trusted).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BehaviorPhase {
/// First contact, insufficient data for classification.
New,
/// Normal traffic pattern, low suspicion.
Normal,
/// Elevated trust after sustained benign behavior.
Trusted,
/// Active reconnaissance (port scanning, service enumeration).
Probing,
/// Systematic scanning (sequential ports, multiple protocols).
Scanning,
/// Exploit attempts detected (high entropy payloads, known signatures).
Exploiting,
/// Established command-and-control pattern (beaconing, exfiltration).
EstablishedC2,
}
impl BehaviorPhase {
/// Numeric suspicion level for ordering (higher = more suspicious).
pub fn suspicion_level(self) -> u8 {
match self {
Self::Trusted => 0,
Self::Normal => 1,
Self::New => 2,
Self::Probing => 3,
Self::Scanning => 4,
Self::Exploiting => 5,
Self::EstablishedC2 => 6,
}
}
/// Whether this phase should trigger active response (block/tarpit).
pub fn is_actionable(self) -> bool {
matches!(self, Self::Scanning | Self::Exploiting | Self::EstablishedC2)
}
}
/// Aggregated behavioral statistics for a single source IP.
pub struct BehaviorProfile {
/// When this IP was first observed.
pub first_seen: Instant,
/// When the last packet was observed.
pub last_seen: Instant,
/// Total packets observed from this IP.
pub total_packets: u64,
/// Unique destination ports contacted.
pub unique_dst_ports: HashSet<u16>,
/// TCP SYN packets (connection attempts).
pub syn_count: u64,
/// TCP ACK packets.
pub ack_count: u64,
/// TCP RST packets (aborted connections).
pub rst_count: u64,
/// TCP FIN packets (clean closes).
pub fin_count: u64,
/// Running sum of entropy scores (for computing average).
pub entropy_sum: u64,
/// Number of entropy samples collected.
pub entropy_samples: u64,
/// Current lifecycle phase.
pub phase: BehaviorPhase,
/// Suspicion score (0.01.0), drives escalation thresholds.
pub suspicion_score: f32,
/// Timestamps of recent packets for inter-arrival analysis (circular, last N).
pub recent_timestamps: Vec<u32>,
/// Maximum recent timestamps to keep.
recent_ts_capacity: usize,
/// Index for circular buffer insertion.
recent_ts_idx: usize,
/// Number of times this IP has been escalated.
pub escalation_count: u32,
}
impl BehaviorProfile {
/// Create a new profile for a first-seen IP.
pub fn new() -> Self {
let now = Instant::now();
let cap = 64;
Self {
first_seen: now,
last_seen: now,
total_packets: 0,
unique_dst_ports: HashSet::new(),
syn_count: 0,
ack_count: 0,
rst_count: 0,
fin_count: 0,
entropy_sum: 0,
entropy_samples: 0,
phase: BehaviorPhase::New,
suspicion_score: 0.0,
recent_timestamps: vec![0u32; cap],
recent_ts_capacity: cap,
recent_ts_idx: 0,
escalation_count: 0,
}
}
/// Ingest a packet event, updating all counters.
pub fn update(&mut self, event: &common::PacketEvent) {
self.last_seen = Instant::now();
self.total_packets += 1;
self.unique_dst_ports.insert(event.dst_port);
// TCP flag counters (protocol 6 = TCP)
if event.protocol == 6 {
if event.flags & 0x02 != 0 {
self.syn_count += 1;
}
if event.flags & 0x10 != 0 {
self.ack_count += 1;
}
if event.flags & 0x04 != 0 {
self.rst_count += 1;
}
if event.flags & 0x01 != 0 {
self.fin_count += 1;
}
}
// Entropy tracking
if event.entropy_score > 0 {
self.entropy_sum += event.entropy_score as u64;
self.entropy_samples += 1;
}
// Circular buffer for inter-arrival times
self.recent_timestamps[self.recent_ts_idx] = event.timestamp_ns;
self.recent_ts_idx = (self.recent_ts_idx + 1) % self.recent_ts_capacity;
}
/// Average byte diversity score (unique_count × 31 scale), or 0 if no samples.
pub fn avg_entropy(&self) -> u32 {
if self.entropy_samples == 0 {
return 0;
}
(self.entropy_sum / self.entropy_samples) as u32
}
/// Ratio of SYN-only packets (SYN without ACK) to total packets.
pub fn syn_only_ratio(&self) -> f32 {
if self.total_packets == 0 {
return 0.0;
}
let syn_only = self.syn_count.saturating_sub(self.ack_count);
syn_only as f32 / self.total_packets as f32
}
/// Duration since first observation.
pub fn age(&self) -> std::time::Duration {
self.last_seen.duration_since(self.first_seen)
}
/// Number of unique destination ports observed.
pub fn port_diversity(&self) -> usize {
self.unique_dst_ports.len()
}
/// Whether this profile has enough data for meaningful classification.
pub fn has_sufficient_data(&self) -> bool {
self.total_packets >= 5
}
/// Detect beaconing: regular inter-arrival times (C2 pattern).
/// Returns the coefficient of variation (stddev/mean) × 1000.
/// Low values (<300) indicate periodic/regular intervals = beaconing.
pub fn beaconing_score(&self) -> Option<u32> {
if self.total_packets < 20 {
return None;
}
// Collect non-zero timestamps from circular buffer
let mut timestamps: Vec<u32> = self.recent_timestamps.iter()
.copied()
.filter(|&t| t > 0)
.collect();
if timestamps.len() < 10 {
return None;
}
timestamps.sort_unstable();
// Compute inter-arrival deltas
let mut deltas = Vec::with_capacity(timestamps.len() - 1);
for w in timestamps.windows(2) {
let delta = w[1].saturating_sub(w[0]);
if delta > 0 {
deltas.push(delta as u64);
}
}
if deltas.len() < 5 {
return None;
}
// Mean
let sum: u64 = deltas.iter().sum();
let mean = sum / deltas.len() as u64;
if mean == 0 {
return None;
}
// Variance (integer arithmetic)
let var_sum: u64 = deltas.iter()
.map(|&d| {
let diff = d.abs_diff(mean);
diff * diff
})
.sum();
let variance = var_sum / deltas.len() as u64;
// Approximate sqrt via integer Newton's method
let stddev = isqrt(variance);
// Coefficient of variation × 1000
Some((stddev * 1000 / mean) as u32)
}
/// Detect slow scanning: few unique ports over a long time window.
/// Returns true if pattern matches (≤ 1 port/min sustained over 10+ minutes).
pub fn is_slow_scanning(&self) -> bool {
let age_secs = self.age().as_secs();
if age_secs < 600 {
// Need at least 10 minutes of observation
return false;
}
let ports = self.port_diversity();
if ports < 3 {
return false;
}
// Rate: ports per minute
let age_mins = age_secs / 60;
if age_mins == 0 {
return false;
}
let ports_per_min = ports as u64 * 100 / age_mins; // ×100 for precision
// ≤ 1.5 ports/min = slow scan (150 in ×100 scale)
ports_per_min <= 150 && ports >= 5
}
/// Advance to a more suspicious phase (never demote except to Trusted).
pub fn escalate_to(&mut self, new_phase: BehaviorPhase) {
if new_phase.suspicion_level() > self.phase.suspicion_level() {
self.phase = new_phase;
self.escalation_count += 1;
}
}
/// Promote to Trusted after sustained benign behavior.
pub fn promote_to_trusted(&mut self) {
self.phase = BehaviorPhase::Trusted;
self.suspicion_score = 0.0;
}
}
/// Integer square root via Newton's method.
fn isqrt(n: u64) -> u64 {
if n == 0 {
return 0;
}
let mut x = n;
let mut y = x.div_ceil(2);
while y < x {
x = y;
y = (x + n / x) / 2;
}
x
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(flags: u8, dst_port: u16, entropy: u32) -> common::PacketEvent {
common::PacketEvent {
src_ip: 0x0100007f, // 127.0.0.1
dst_ip: 0x0200007f,
src_port: 12345,
dst_port,
protocol: 6,
flags,
payload_len: 64,
entropy_score: entropy,
timestamp_ns: 0,
_padding: 0,
packet_size: 128,
}
}
#[test]
fn new_profile_defaults() {
let p = BehaviorProfile::new();
assert_eq!(p.phase, BehaviorPhase::New);
assert_eq!(p.total_packets, 0);
assert_eq!(p.suspicion_score, 0.0);
}
#[test]
fn update_increments_counters() {
let mut p = BehaviorProfile::new();
let syn_event = make_event(0x02, 80, 3000);
p.update(&syn_event);
assert_eq!(p.total_packets, 1);
assert_eq!(p.syn_count, 1);
assert_eq!(p.ack_count, 0);
assert_eq!(p.unique_dst_ports.len(), 1);
}
#[test]
fn avg_entropy_calculation() {
let mut p = BehaviorProfile::new();
p.update(&make_event(0x02, 80, 6000));
p.update(&make_event(0x02, 443, 8000));
assert_eq!(p.avg_entropy(), 7000);
}
#[test]
fn syn_only_ratio() {
let mut p = BehaviorProfile::new();
// 3 SYN-only
for port in 1..=3 {
p.update(&make_event(0x02, port, 0));
}
// 1 SYN+ACK
p.update(&make_event(0x12, 80, 0));
// syn_count=4, ack_count=1, total=4
// syn_only = 4-1 = 3, ratio = 3/4 = 0.75
assert!((p.syn_only_ratio() - 0.75).abs() < 0.01);
}
#[test]
fn escalation_monotonic() {
let mut p = BehaviorProfile::new();
p.escalate_to(BehaviorPhase::Probing);
assert_eq!(p.phase, BehaviorPhase::Probing);
// Cannot go back to New
p.escalate_to(BehaviorPhase::New);
assert_eq!(p.phase, BehaviorPhase::Probing);
// Can go forward to Scanning
p.escalate_to(BehaviorPhase::Scanning);
assert_eq!(p.phase, BehaviorPhase::Scanning);
assert_eq!(p.escalation_count, 2);
}
#[test]
fn trusted_promotion() {
let mut p = BehaviorProfile::new();
p.escalate_to(BehaviorPhase::Normal);
p.suspicion_score = 0.3;
p.promote_to_trusted();
assert_eq!(p.phase, BehaviorPhase::Trusted);
assert_eq!(p.suspicion_score, 0.0);
}
#[test]
fn phase_suspicion_ordering() {
assert!(BehaviorPhase::Trusted.suspicion_level() < BehaviorPhase::Normal.suspicion_level());
assert!(BehaviorPhase::Normal.suspicion_level() < BehaviorPhase::Probing.suspicion_level());
assert!(
BehaviorPhase::Probing.suspicion_level() < BehaviorPhase::Scanning.suspicion_level()
);
assert!(
BehaviorPhase::Scanning.suspicion_level()
< BehaviorPhase::Exploiting.suspicion_level()
);
assert!(
BehaviorPhase::Exploiting.suspicion_level()
< BehaviorPhase::EstablishedC2.suspicion_level()
);
}
#[test]
fn actionable_phases() {
assert!(!BehaviorPhase::New.is_actionable());
assert!(!BehaviorPhase::Normal.is_actionable());
assert!(!BehaviorPhase::Trusted.is_actionable());
assert!(!BehaviorPhase::Probing.is_actionable());
assert!(BehaviorPhase::Scanning.is_actionable());
assert!(BehaviorPhase::Exploiting.is_actionable());
assert!(BehaviorPhase::EstablishedC2.is_actionable());
}
#[test]
fn beaconing_insufficient_data() {
let p = BehaviorProfile::new();
assert!(p.beaconing_score().is_none());
}
#[test]
fn beaconing_regular_intervals() {
let mut p = BehaviorProfile::new();
// Simulate 30 packets with regular timestamps (every 1000ns)
for i in 0..30 {
let mut e = make_event(0x12, 443, 2000);
e.timestamp_ns = (i + 1) * 1000;
p.update(&e);
}
if let Some(cv) = p.beaconing_score() {
// Regular intervals → low coefficient of variation
assert!(cv < 300, "expected low CV for regular beaconing, got {}", cv);
}
}
#[test]
fn slow_scan_detection() {
let mut p = BehaviorProfile::new();
// Simulate slow scanning: spread ports over time
// We can't easily fake elapsed time, but we can set up the port diversity
for port in 1..=10 {
p.update(&make_event(0x02, port, 0));
}
// With 10 ports and <10min age, should NOT detect
assert!(!p.is_slow_scanning());
}
#[test]
fn isqrt_values() {
assert_eq!(super::isqrt(0), 0);
assert_eq!(super::isqrt(1), 1);
assert_eq!(super::isqrt(4), 2);
assert_eq!(super::isqrt(9), 3);
assert_eq!(super::isqrt(100), 10);
assert_eq!(super::isqrt(1000000), 1000);
}
}

View file

@ -0,0 +1,337 @@
//! Deterministic state transitions for the behavioral engine.
//!
//! Evaluates a `BehaviorProfile` against threshold-based rules and returns
//! a `TransitionVerdict` indicating whether to escalate, hold, or promote.
use super::profile::{BehaviorPhase, BehaviorProfile};
// --- Transition thresholds ---
/// Unique destination ports to trigger Probing escalation.
const PROBING_PORT_THRESHOLD: usize = 5;
/// Unique destination ports to trigger Scanning escalation.
const SCANNING_PORT_THRESHOLD: usize = 20;
/// SYN-only ratio above this → likely SYN flood or scan.
const SYN_FLOOD_RATIO: f32 = 0.8;
/// Minimum packets for SYN flood detection.
const SYN_FLOOD_MIN_PACKETS: u64 = 50;
/// Average byte diversity score above this → encrypted/exploit payload.
const EXPLOIT_ENTROPY_THRESHOLD: u32 = 7500;
/// Minimum entropy samples for exploit detection.
const EXPLOIT_MIN_SAMPLES: u64 = 10;
/// RST ratio above this (with sufficient packets) → scanning/exploit.
const RST_RATIO_THRESHOLD: f32 = 0.5;
/// Minimum packets to evaluate RST ratio.
const RST_RATIO_MIN_PACKETS: u64 = 20;
/// Beaconing coefficient of variation threshold (×1000).
/// Values below this indicate highly regular intervals (C2 beaconing).
const BEACONING_CV_THRESHOLD: u32 = 300;
/// Packets needed to promote New → Normal.
const NORMAL_PACKET_THRESHOLD: u64 = 10;
/// Seconds of benign activity before promoting Normal → Trusted.
const TRUSTED_AGE_SECS: u64 = 300;
/// Minimum packets for Trusted promotion.
const TRUSTED_PACKET_THRESHOLD: u64 = 100;
/// Suspicion score increase per escalation event.
const SUSPICION_INCREMENT: f32 = 0.15;
/// Maximum suspicion score.
const SUSPICION_MAX: f32 = 1.0;
/// Suspicion decay per evaluation when no escalation.
const SUSPICION_DECAY: f32 = 0.02;
/// Result of evaluating a profile's behavioral transitions.
#[derive(Debug, Clone, PartialEq)]
pub enum TransitionVerdict {
/// No phase change, continue monitoring.
Hold,
/// Escalate to a more suspicious phase.
Escalate {
from: BehaviorPhase,
to: BehaviorPhase,
reason: &'static str,
},
/// Promote to a less suspicious phase (Trusted).
Promote {
from: BehaviorPhase,
to: BehaviorPhase,
},
}
/// Evaluate a profile and apply deterministic transitions.
/// Returns the verdict and mutates the profile in place.
pub fn evaluate_transitions(profile: &mut BehaviorProfile) -> TransitionVerdict {
if !profile.has_sufficient_data() {
return TransitionVerdict::Hold;
}
let current = profile.phase;
// --- Check for escalation conditions (highest severity first) ---
// C2 beaconing: sustained high entropy with regular intervals + many packets
if current.suspicion_level() < BehaviorPhase::EstablishedC2.suspicion_level()
&& profile.avg_entropy() > EXPLOIT_ENTROPY_THRESHOLD
&& profile.total_packets > 200
&& profile.port_diversity() <= 3
&& profile.age().as_secs() > 60
{
return apply_escalation(
profile,
BehaviorPhase::EstablishedC2,
"sustained high entropy with low port diversity (C2 pattern)",
);
}
// C2 beaconing: regular inter-arrival intervals (even without high entropy)
if current.suspicion_level() < BehaviorPhase::EstablishedC2.suspicion_level()
&& profile.total_packets > 100
&& profile.age().as_secs() > 120
{
if let Some(cv) = profile.beaconing_score() {
if cv < BEACONING_CV_THRESHOLD && profile.port_diversity() <= 3 {
return apply_escalation(
profile,
BehaviorPhase::EstablishedC2,
"regular beaconing intervals detected (C2 callback pattern)",
);
}
}
}
// Exploit: high entropy payloads
if current.suspicion_level() < BehaviorPhase::Exploiting.suspicion_level()
&& profile.avg_entropy() > EXPLOIT_ENTROPY_THRESHOLD
&& profile.entropy_samples >= EXPLOIT_MIN_SAMPLES
{
return apply_escalation(
profile,
BehaviorPhase::Exploiting,
"high entropy payloads (encrypted/exploit traffic)",
);
}
// Scanning: many unique ports
if current.suspicion_level() < BehaviorPhase::Scanning.suspicion_level()
&& profile.port_diversity() > SCANNING_PORT_THRESHOLD
{
return apply_escalation(
profile,
BehaviorPhase::Scanning,
"extensive port scanning (>20 unique ports)",
);
}
// SYN flood: high SYN-only ratio with sufficient volume
if current.suspicion_level() < BehaviorPhase::Scanning.suspicion_level()
&& profile.syn_only_ratio() > SYN_FLOOD_RATIO
&& profile.total_packets >= SYN_FLOOD_MIN_PACKETS
{
return apply_escalation(
profile,
BehaviorPhase::Scanning,
"SYN flood pattern (>80% SYN-only, >50 packets)",
);
}
// Slow scan: few ports over a long time window (stealth reconnaissance)
if current.suspicion_level() < BehaviorPhase::Scanning.suspicion_level()
&& profile.is_slow_scanning()
{
return apply_escalation(
profile,
BehaviorPhase::Scanning,
"slow scan pattern (≤1.5 ports/min over 10+ minutes)",
);
}
// RST storm: many connection resets (scanner getting rejected)
if current.suspicion_level() < BehaviorPhase::Probing.suspicion_level()
&& profile.total_packets >= RST_RATIO_MIN_PACKETS
{
let rst_ratio = profile.rst_count as f32 / profile.total_packets as f32;
if rst_ratio > RST_RATIO_THRESHOLD {
return apply_escalation(
profile,
BehaviorPhase::Probing,
"high RST ratio (>50%, scanning likely rejected)",
);
}
}
// Probing: moderate port diversity
if current.suspicion_level() < BehaviorPhase::Probing.suspicion_level()
&& profile.port_diversity() > PROBING_PORT_THRESHOLD
{
return apply_escalation(
profile,
BehaviorPhase::Probing,
"port diversity above probing threshold (>5 unique ports)",
);
}
// --- Check for promotion conditions ---
// New → Normal: sufficient packets without triggering any escalation
if current == BehaviorPhase::New && profile.total_packets >= NORMAL_PACKET_THRESHOLD {
profile.phase = BehaviorPhase::Normal;
return TransitionVerdict::Promote {
from: BehaviorPhase::New,
to: BehaviorPhase::Normal,
};
}
// Normal → Trusted: sustained benign behavior
if current == BehaviorPhase::Normal
&& profile.age().as_secs() >= TRUSTED_AGE_SECS
&& profile.total_packets >= TRUSTED_PACKET_THRESHOLD
&& profile.suspicion_score < 0.1
{
profile.promote_to_trusted();
return TransitionVerdict::Promote {
from: BehaviorPhase::Normal,
to: BehaviorPhase::Trusted,
};
}
// --- No transition: decay suspicion slightly ---
profile.suspicion_score = (profile.suspicion_score - SUSPICION_DECAY).max(0.0);
TransitionVerdict::Hold
}
/// Apply an escalation: update phase, bump suspicion, return verdict.
fn apply_escalation(
profile: &mut BehaviorProfile,
target: BehaviorPhase,
reason: &'static str,
) -> TransitionVerdict {
let from = profile.phase;
profile.escalate_to(target);
profile.suspicion_score = (profile.suspicion_score + SUSPICION_INCREMENT).min(SUSPICION_MAX);
TransitionVerdict::Escalate {
from,
to: target,
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(flags: u8, dst_port: u16, entropy: u32) -> common::PacketEvent {
common::PacketEvent {
src_ip: 0x0100007f,
dst_ip: 0x0200007f,
src_port: 12345,
dst_port,
protocol: 6,
flags,
payload_len: 64,
entropy_score: entropy,
timestamp_ns: 0,
_padding: 0,
packet_size: 128,
}
}
#[test]
fn insufficient_data_holds() {
let mut p = BehaviorProfile::new();
p.update(&make_event(0x02, 80, 3000));
assert_eq!(evaluate_transitions(&mut p), TransitionVerdict::Hold);
}
#[test]
fn new_to_normal_promotion() {
let mut p = BehaviorProfile::new();
// 10 benign packets to same port
for _ in 0..10 {
p.update(&make_event(0x12, 80, 2000)); // SYN+ACK
}
let v = evaluate_transitions(&mut p);
assert_eq!(
v,
TransitionVerdict::Promote {
from: BehaviorPhase::New,
to: BehaviorPhase::Normal,
}
);
assert_eq!(p.phase, BehaviorPhase::Normal);
}
#[test]
fn port_scan_escalation() {
let mut p = BehaviorProfile::new();
// Hit 25 unique ports (> SCANNING_PORT_THRESHOLD=20)
for port in 1..=25 {
p.update(&make_event(0x02, port, 1000));
}
let v = evaluate_transitions(&mut p);
match v {
TransitionVerdict::Escalate { to, reason, .. } => {
assert_eq!(to, BehaviorPhase::Scanning);
assert!(reason.contains("port scanning"));
}
other => panic!("expected Scanning escalation, got {:?}", other),
}
}
#[test]
fn syn_flood_escalation() {
let mut p = BehaviorProfile::new();
// 60 SYN-only packets to same port
for _ in 0..60 {
p.update(&make_event(0x02, 80, 0));
}
let v = evaluate_transitions(&mut p);
match v {
TransitionVerdict::Escalate { to, reason, .. } => {
assert_eq!(to, BehaviorPhase::Scanning);
assert!(reason.contains("SYN flood"));
}
other => panic!("expected SYN flood escalation, got {:?}", other),
}
}
#[test]
fn high_entropy_exploit() {
let mut p = BehaviorProfile::new();
// 15 high-entropy packets
for port in 1..=15 {
p.update(&make_event(0x12, port, 7800));
}
let v = evaluate_transitions(&mut p);
match v {
TransitionVerdict::Escalate { to, reason, .. } => {
assert_eq!(to, BehaviorPhase::Exploiting);
assert!(reason.contains("entropy"));
}
other => panic!("expected Exploiting escalation, got {:?}", other),
}
}
#[test]
fn suspicion_increases_on_escalation() {
let mut p = BehaviorProfile::new();
assert_eq!(p.suspicion_score, 0.0);
for port in 1..=6 {
p.update(&make_event(0x02, port, 1000));
}
evaluate_transitions(&mut p);
assert!(p.suspicion_score > 0.0);
}
#[test]
fn suspicion_decays_when_benign() {
let mut p = BehaviorProfile::new();
p.suspicion_score = 0.5;
p.phase = BehaviorPhase::Normal;
// Feed benign traffic (same port, low entropy)
for _ in 0..10 {
p.update(&make_event(0x12, 80, 2000));
}
evaluate_transitions(&mut p);
assert!(p.suspicion_score < 0.5);
}
}

367
blackwall/src/config.rs Executable file
View file

@ -0,0 +1,367 @@
use serde::Deserialize;
use std::path::Path;
/// Top-level daemon configuration, loaded from TOML.
#[derive(Deserialize)]
pub struct Config {
pub network: NetworkConfig,
#[serde(default)]
#[allow(dead_code)]
pub thresholds: ThresholdConfig,
#[serde(default)]
pub tarpit: TarpitConfig,
#[serde(default)]
pub ai: AiConfig,
#[serde(default)]
pub rules: RulesConfig,
#[serde(default)]
pub feeds: FeedsConfig,
#[serde(default)]
pub pcap: PcapConfig,
#[serde(default)]
#[allow(dead_code)]
pub distributed: DistributedConfig,
}
/// Network / XDP attachment settings.
#[derive(Deserialize)]
pub struct NetworkConfig {
/// Network interface to attach XDP program to.
#[serde(default = "default_interface")]
pub interface: String,
/// XDP attach mode: "generic", "native", or "offload".
#[serde(default = "default_xdp_mode")]
pub xdp_mode: String,
}
/// Anomaly detection thresholds.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct ThresholdConfig {
/// Byte diversity score above which a packet is considered anomalous.
#[serde(default = "default_entropy_anomaly")]
pub entropy_anomaly: u32,
}
/// Tarpit honeypot configuration.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct TarpitConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_tarpit_port")]
pub port: u16,
#[serde(default = "default_base_delay")]
pub base_delay_ms: u64,
#[serde(default = "default_max_delay")]
pub max_delay_ms: u64,
#[serde(default = "default_jitter")]
pub jitter_ms: u64,
/// Per-protocol deception service port overrides.
#[serde(default)]
pub services: DeceptionServicesConfig,
}
/// Per-protocol port configuration for the deception mesh.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct DeceptionServicesConfig {
/// SSH honeypot port (default: 22).
#[serde(default = "default_ssh_port")]
pub ssh_port: u16,
/// HTTP honeypot port (default: 80).
#[serde(default = "default_http_port")]
pub http_port: u16,
/// MySQL honeypot port (default: 3306).
#[serde(default = "default_mysql_port")]
pub mysql_port: u16,
/// DNS canary port (default: 53).
#[serde(default = "default_dns_port")]
pub dns_port: u16,
}
/// AI / LLM classification settings.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct AiConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_ollama_url")]
pub ollama_url: String,
#[serde(default = "default_model")]
pub model: String,
#[serde(default = "default_fallback_model")]
pub fallback_model: String,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
}
/// Static rules loaded at startup.
#[derive(Deserialize, Default)]
pub struct RulesConfig {
#[serde(default)]
pub blocklist: Vec<String>,
#[serde(default)]
pub allowlist: Vec<String>,
}
/// Threat feed configuration.
#[derive(Deserialize)]
pub struct FeedsConfig {
/// Whether threat feed fetching is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Refresh interval in seconds (default: 1 hour).
#[serde(default = "default_feed_refresh_secs")]
pub refresh_interval_secs: u64,
/// Block duration for feed-sourced IPs in seconds (default: 1 hour).
#[serde(default = "default_feed_block_secs")]
pub block_duration_secs: u32,
/// Feed source URLs.
#[serde(default = "default_feed_sources")]
pub sources: Vec<FeedSourceConfig>,
}
/// A single threat feed source entry.
#[derive(Deserialize, Clone)]
pub struct FeedSourceConfig {
pub name: String,
pub url: String,
/// Override block duration for this feed (uses parent default if absent).
pub block_duration_secs: Option<u32>,
}
/// PCAP forensic capture configuration.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct PcapConfig {
/// Whether PCAP capture is enabled.
#[serde(default)]
pub enabled: bool,
/// Output directory for pcap files.
#[serde(default = "default_pcap_dir")]
pub output_dir: String,
/// Maximum pcap file size in MB before rotation.
#[serde(default = "default_pcap_max_size")]
pub max_size_mb: u64,
/// Maximum number of rotated pcap files to keep.
#[serde(default = "default_pcap_max_files")]
pub max_files: usize,
/// Compress rotated pcap files with gzip.
#[serde(default)]
pub compress_rotated: bool,
}
/// Distributed coordination configuration.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct DistributedConfig {
/// Whether distributed mode is enabled.
#[serde(default)]
pub enabled: bool,
/// Mode: "sensor" (reports to controller) or "standalone" (default).
#[serde(default = "default_distributed_mode")]
pub mode: String,
/// Peer addresses to connect to.
#[serde(default)]
pub peers: Vec<String>,
/// Port to listen for peer connections.
#[serde(default = "default_peer_port")]
pub bind_port: u16,
/// Node identifier (auto-generated if empty).
#[serde(default)]
pub node_id: String,
/// Pre-shared key for HMAC-SHA256 peer authentication.
/// All peers in the mesh must share the same PSK.
/// If empty, distributed mode refuses to start.
#[serde(default)]
pub peer_psk: String,
}
// --- Defaults ---
fn default_interface() -> String {
"eth0".into()
}
fn default_xdp_mode() -> String {
"generic".into()
}
fn default_entropy_anomaly() -> u32 {
common::ENTROPY_ANOMALY_THRESHOLD
}
fn default_true() -> bool {
true
}
fn default_tarpit_port() -> u16 {
common::TARPIT_PORT
}
fn default_base_delay() -> u64 {
common::TARPIT_BASE_DELAY_MS
}
fn default_max_delay() -> u64 {
common::TARPIT_MAX_DELAY_MS
}
fn default_jitter() -> u64 {
common::TARPIT_JITTER_MS
}
fn default_ollama_url() -> String {
"http://localhost:11434".into()
}
fn default_model() -> String {
"qwen3:1.7b".into()
}
fn default_fallback_model() -> String {
"qwen3:0.6b".into()
}
fn default_max_tokens() -> u32 {
512
}
fn default_timeout_ms() -> u64 {
5000
}
fn default_feed_refresh_secs() -> u64 {
3600
}
fn default_feed_block_secs() -> u32 {
3600
}
fn default_feed_sources() -> Vec<FeedSourceConfig> {
vec![
FeedSourceConfig {
name: "firehol-level1".into(),
url: "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset".into(),
block_duration_secs: None,
},
FeedSourceConfig {
name: "feodo-tracker".into(),
url: "https://feodotracker.abuse.ch/downloads/ipblocklist.txt".into(),
block_duration_secs: None,
},
]
}
fn default_pcap_dir() -> String {
"/var/lib/blackwall/pcap".into()
}
fn default_pcap_max_size() -> u64 {
100
}
fn default_pcap_max_files() -> usize {
10
}
fn default_ssh_port() -> u16 {
22
}
fn default_http_port() -> u16 {
80
}
fn default_mysql_port() -> u16 {
3306
}
fn default_dns_port() -> u16 {
53
}
fn default_distributed_mode() -> String {
"standalone".into()
}
fn default_peer_port() -> u16 {
9471
}
impl Default for NetworkConfig {
fn default() -> Self {
Self {
interface: default_interface(),
xdp_mode: default_xdp_mode(),
}
}
}
impl Default for ThresholdConfig {
fn default() -> Self {
Self {
entropy_anomaly: default_entropy_anomaly(),
}
}
}
impl Default for TarpitConfig {
fn default() -> Self {
Self {
enabled: true,
port: default_tarpit_port(),
base_delay_ms: default_base_delay(),
max_delay_ms: default_max_delay(),
jitter_ms: default_jitter(),
services: DeceptionServicesConfig::default(),
}
}
}
impl Default for DeceptionServicesConfig {
fn default() -> Self {
Self {
ssh_port: default_ssh_port(),
http_port: default_http_port(),
mysql_port: default_mysql_port(),
dns_port: default_dns_port(),
}
}
}
impl Default for AiConfig {
fn default() -> Self {
Self {
enabled: true,
ollama_url: default_ollama_url(),
model: default_model(),
fallback_model: default_fallback_model(),
max_tokens: default_max_tokens(),
timeout_ms: default_timeout_ms(),
}
}
}
impl Default for FeedsConfig {
fn default() -> Self {
Self {
enabled: true,
refresh_interval_secs: default_feed_refresh_secs(),
block_duration_secs: default_feed_block_secs(),
sources: default_feed_sources(),
}
}
}
impl Default for PcapConfig {
fn default() -> Self {
Self {
enabled: false,
output_dir: default_pcap_dir(),
max_size_mb: default_pcap_max_size(),
max_files: default_pcap_max_files(),
compress_rotated: false,
}
}
}
impl Default for DistributedConfig {
fn default() -> Self {
Self {
enabled: false,
mode: default_distributed_mode(),
peers: Vec::new(),
bind_port: default_peer_port(),
node_id: String::new(),
peer_psk: String::new(),
}
}
}
/// Load configuration from a TOML file.
pub fn load_config(path: &Path) -> anyhow::Result<Config> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}

View file

@ -0,0 +1,11 @@
//! Distributed coordination module for multi-instance Blackwall deployments.
//!
//! Enables multiple Blackwall nodes to share threat intelligence via a
//! simple peer-to-peer protocol over TCP. Nodes exchange blocked IPs,
//! JA4 fingerprints, and behavioral observations.
pub mod peer;
pub mod proto;
#[allow(unused_imports)]
pub use peer::{broadcast_block, PeerManager};

500
blackwall/src/distributed/peer.rs Executable file
View file

@ -0,0 +1,500 @@
//! Peer management: discovery, connection, and message exchange.
//!
//! Manages connections to other Blackwall nodes for distributed
//! threat intelligence sharing.
#![allow(dead_code)]
use anyhow::{Context, Result};
use ring::hmac;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::proto::{self, BlockedIpPayload, HelloPayload, MessageType, HEADER_SIZE};
/// Default port for peer communication.
pub const DEFAULT_PEER_PORT: u16 = 9471;
/// Heartbeat interval.
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// Peer connection timeout.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Maximum peers to maintain.
const MAX_PEERS: usize = 16;
/// Maximum message payload size (64 KB).
const MAX_PAYLOAD_SIZE: usize = 65536;
/// Known peer state.
struct PeerState {
addr: SocketAddr,
node_id: Option<String>,
last_seen: Instant,
blocked_count: u32,
/// Persistent outbound connection for broadcasts (reused across sends).
outbound: Option<TcpStream>,
}
/// Manages distributed peer connections and threat intel sharing.
pub struct PeerManager {
/// Our node identifier
node_id: String,
/// Known peers with their state
peers: HashMap<SocketAddr, PeerState>,
/// IPs received from peers (ip → source_peer)
shared_blocks: HashMap<Ipv4Addr, SocketAddr>,
/// HMAC-SHA256 key derived from peer PSK for message authentication.
hmac_key: hmac::Key,
}
impl PeerManager {
/// Create a new peer manager with the given node ID and pre-shared key.
pub fn new(node_id: String, peer_psk: &[u8]) -> Self {
Self {
node_id,
peers: HashMap::new(),
shared_blocks: HashMap::new(),
hmac_key: hmac::Key::new(hmac::HMAC_SHA256, peer_psk),
}
}
/// Get reference to the HMAC key for message signing/verification.
pub fn hmac_key(&self) -> &hmac::Key {
&self.hmac_key
}
/// Add a peer address to the known peers list.
pub fn add_peer(&mut self, addr: SocketAddr) {
if self.peers.len() >= MAX_PEERS {
tracing::warn!("max peers reached, ignoring {}", addr);
return;
}
self.peers.entry(addr).or_insert_with(|| PeerState {
addr,
node_id: None,
last_seen: Instant::now(),
blocked_count: 0,
outbound: None,
});
}
/// Get count of known peers.
pub fn peer_count(&self) -> usize {
self.peers.len()
}
/// Get count of shared block entries received from peers.
#[allow(dead_code)]
pub fn shared_block_count(&self) -> usize {
self.shared_blocks.len()
}
/// Process a received blocked IP notification from a peer.
pub fn receive_blocked_ip(
&mut self,
from: SocketAddr,
payload: &BlockedIpPayload,
) -> Option<(Ipv4Addr, u32)> {
// Only accept if confidence is reasonable
if payload.confidence < 50 {
tracing::debug!(
peer = %from,
ip = %payload.ip,
confidence = payload.confidence,
"ignoring low-confidence peer block"
);
return None;
}
self.shared_blocks.insert(payload.ip, from);
tracing::info!(
peer = %from,
ip = %payload.ip,
reason = %payload.reason,
confidence = payload.confidence,
"received blocked IP from peer"
);
Some((payload.ip, payload.duration_secs))
}
/// Create a hello payload for this node.
pub fn make_hello(&self, blocked_count: u32) -> HelloPayload {
HelloPayload {
node_id: self.node_id.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
blocked_count,
}
}
/// Handle an incoming hello from a peer.
pub fn handle_hello(&mut self, from: SocketAddr, hello: &HelloPayload) {
if let Some(peer) = self.peers.get_mut(&from) {
peer.node_id = Some(hello.node_id.clone());
peer.last_seen = Instant::now();
peer.blocked_count = hello.blocked_count;
}
tracing::info!(
peer = %from,
node_id = %hello.node_id,
blocked = hello.blocked_count,
"peer hello received"
);
}
/// Prune peers that haven't been seen in a while.
pub fn prune_stale_peers(&mut self, max_age: Duration) {
let before = self.peers.len();
self.peers.retain(|_, p| p.last_seen.elapsed() < max_age);
let pruned = before - self.peers.len();
if pruned > 0 {
tracing::info!(count = pruned, "pruned stale peers");
}
}
/// Get addresses of all known peers.
pub fn peer_addrs(&self) -> Vec<SocketAddr> {
self.peers.keys().copied().collect()
}
/// Take outbound streams from all peers for concurrent use.
/// Returns (addr, Option<TcpStream>) pairs. Caller MUST return streams
/// via `return_outbound()` after use.
fn take_outbound_streams(&mut self) -> Vec<(SocketAddr, Option<TcpStream>)> {
self.peers
.iter_mut()
.map(|(addr, state)| (*addr, state.outbound.take()))
.collect()
}
/// Return an outbound stream (or None if it broke) to a peer.
fn return_outbound(&mut self, addr: &SocketAddr, stream: Option<TcpStream>) {
if let Some(peer) = self.peers.get_mut(addr) {
peer.outbound = stream;
}
}
}
/// Broadcast a blocked IP to all known peers.
///
/// Reuses persistent outbound TCP connections where possible. Creates new
/// connections only when no existing stream is available or the previous
/// one has broken. Sends in parallel; individual peer failures are logged
/// and do not block other peers.
pub async fn broadcast_block(
manager: &std::sync::Arc<tokio::sync::Mutex<PeerManager>>,
payload: &BlockedIpPayload,
) {
let peers = {
let mut mgr = manager.lock().await;
mgr.take_outbound_streams()
};
if peers.is_empty() {
return;
}
tracing::info!(
ip = %payload.ip,
peers = peers.len(),
"broadcasting block to peers"
);
let json = match serde_json::to_vec(payload) {
Ok(j) => j,
Err(e) => {
// Return streams untouched on serialization failure
let mut mgr = manager.lock().await;
for (addr, stream) in peers {
mgr.return_outbound(&addr, stream);
}
tracing::warn!(error = %e, "failed to serialize BlockedIpPayload");
return;
}
};
let msg = {
let mgr = manager.lock().await;
proto::encode_message(MessageType::BlockedIp, &json, mgr.hmac_key())
};
let mut tasks = Vec::with_capacity(peers.len());
for (addr, existing) in peers {
let msg_clone = msg.clone();
tasks.push(tokio::spawn(async move {
let stream = send_or_reconnect(addr, existing, &msg_clone).await;
(addr, stream)
}));
}
// Return streams (live or None) back to the manager
let mut mgr = manager.lock().await;
for task in tasks {
if let Ok((addr, stream)) = task.await {
mgr.return_outbound(&addr, stream);
}
}
}
/// Try to send on an existing connection; reconnect if broken.
/// Returns the stream if it's still alive, or None if the peer is unreachable.
async fn send_or_reconnect(
addr: SocketAddr,
existing: Option<TcpStream>,
msg: &[u8],
) -> Option<TcpStream> {
// Try existing connection first
if let Some(mut stream) = existing {
if stream.write_all(msg).await.is_ok() && stream.flush().await.is_ok() {
return Some(stream);
}
// Connection broken — fall through to reconnect
tracing::debug!(peer = %addr, "outbound stream broken, reconnecting");
}
// Create new connection
let mut stream = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
tracing::warn!(peer = %addr, error = %e, "peer connect failed");
return None;
}
Err(_) => {
tracing::warn!(peer = %addr, "peer connect timeout");
return None;
}
};
if stream.write_all(msg).await.is_ok() && stream.flush().await.is_ok() {
Some(stream)
} else {
tracing::warn!(peer = %addr, "failed to write to new peer connection");
None
}
}
/// Send a blocked IP notification to a single peer (one-off, no pool).
pub async fn send_blocked_ip(
addr: SocketAddr,
payload: &BlockedIpPayload,
key: &hmac::Key,
) -> Result<()> {
let json = serde_json::to_vec(payload).context("serialize BlockedIpPayload")?;
let msg = proto::encode_message(MessageType::BlockedIp, &json, key);
let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.context("peer connect timeout")?
.context("peer connect failed")?;
stream.write_all(&msg).await.context("peer write failed")?;
stream.flush().await?;
Ok(())
}
/// Listen for incoming peer connections and process messages.
pub async fn listen_for_peers(
bind_addr: SocketAddr,
manager: std::sync::Arc<tokio::sync::Mutex<PeerManager>>,
) -> Result<()> {
let listener = TcpListener::bind(bind_addr)
.await
.context("failed to bind peer listener")?;
tracing::info!(addr = %bind_addr, "peer listener started");
loop {
let (mut stream, peer_addr) = listener.accept().await?;
let mgr = manager.clone();
tokio::spawn(async move {
if let Err(e) = handle_peer_connection(&mut stream, peer_addr, &mgr).await {
tracing::warn!(peer = %peer_addr, "peer connection ended: {}", e);
}
});
}
}
/// Handle an incoming peer connection — loops reading messages until the
/// remote side disconnects or an I/O error occurs. Sends responses to HELLO
/// and Heartbeat messages so the controller can track liveness.
///
/// Non-fatal errors (bad JSON, unknown payloads) are logged and the loop
/// continues. Only I/O errors (connection reset, EOF) break the loop.
async fn handle_peer_connection(
stream: &mut TcpStream,
peer_addr: SocketAddr,
manager: &std::sync::Arc<tokio::sync::Mutex<PeerManager>>,
) -> Result<()> {
// Disable Nagle's algorithm for low-latency responses
let _ = stream.set_nodelay(true);
tracing::info!(peer = %peer_addr, "peer connected, entering read loop");
loop {
// --- Read header: magic(4) + type(1) + payload_len(4) + hmac(32) ---
let mut header_buf = [0u8; HEADER_SIZE];
stream.read_exact(&mut header_buf).await?;
let (msg_type, payload_len) = match proto::decode_header(&header_buf) {
Some(h) => h,
None => {
tracing::warn!(
peer = %peer_addr,
"invalid header (bad magic or unknown type), dropping connection"
);
anyhow::bail!("invalid header from {}", peer_addr);
}
};
if payload_len > MAX_PAYLOAD_SIZE {
tracing::warn!(
peer = %peer_addr, len = payload_len,
"payload too large, dropping connection"
);
anyhow::bail!("payload too large: {}", payload_len);
}
// --- Read payload (I/O error is fatal) ---
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
stream.read_exact(&mut payload).await?;
}
// --- Verify HMAC-SHA256 before processing ---
{
let mgr = manager.lock().await;
if !proto::verify_hmac(&header_buf, &payload, mgr.hmac_key()) {
tracing::warn!(
peer = %peer_addr, msg_type = ?msg_type,
"HMAC verification failed — rejecting message"
);
anyhow::bail!("HMAC verification failed from {}", peer_addr);
}
}
// --- Process message (parse errors are non-fatal → continue) ---
match msg_type {
MessageType::Hello => {
let hello: HelloPayload = match serde_json::from_slice(&payload) {
Ok(h) => h,
Err(e) => {
tracing::warn!(peer = %peer_addr, error = %e, "bad Hello JSON, skipping");
continue;
}
};
let resp_msg = {
let mut mgr = manager.lock().await;
// Auto-register the peer so future heartbeats update last_seen
mgr.add_peer(peer_addr);
mgr.handle_hello(peer_addr, &hello);
let resp = mgr.make_hello(mgr.shared_block_count() as u32);
let resp_bytes = serde_json::to_vec(&resp)
.context("serialize Hello response")?;
proto::encode_message(MessageType::Hello, &resp_bytes, mgr.hmac_key())
}; // mgr dropped here before I/O
stream.write_all(&resp_msg).await?;
stream.flush().await?;
}
MessageType::BlockedIp => {
let blocked: BlockedIpPayload = match serde_json::from_slice(&payload) {
Ok(b) => b,
Err(e) => {
tracing::warn!(peer = %peer_addr, error = %e, "bad BlockedIp JSON, skipping");
continue;
}
};
let mut mgr = manager.lock().await;
mgr.receive_blocked_ip(peer_addr, &blocked);
}
MessageType::Heartbeat => {
let resp_msg = {
let mut mgr = manager.lock().await;
if let Some(peer) = mgr.peers.get_mut(&peer_addr) {
peer.last_seen = Instant::now();
}
let resp = mgr.make_hello(mgr.shared_block_count() as u32);
let resp_bytes = serde_json::to_vec(&resp)
.context("serialize Heartbeat response")?;
proto::encode_message(MessageType::Hello, &resp_bytes, mgr.hmac_key())
}; // mgr dropped here before I/O
stream.write_all(&resp_msg).await?;
stream.flush().await?;
}
_ => {
tracing::debug!(
peer = %peer_addr, msg_type = ?msg_type,
"unhandled message type, continuing"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_psk() -> &'static [u8] {
b"test-secret-key-for-blackwall"
}
#[test]
fn peer_manager_add_and_count() {
let mut mgr = PeerManager::new("test-node".into(), test_psk());
assert_eq!(mgr.peer_count(), 0);
mgr.add_peer("10.0.0.1:9471".parse().unwrap());
assert_eq!(mgr.peer_count(), 1);
// Duplicate
mgr.add_peer("10.0.0.1:9471".parse().unwrap());
assert_eq!(mgr.peer_count(), 1);
}
#[test]
fn receive_blocked_ip_with_confidence() {
let mut mgr = PeerManager::new("test-node".into(), test_psk());
let peer: SocketAddr = "10.0.0.2:9471".parse().unwrap();
mgr.add_peer(peer);
// High confidence — accepted
let high = BlockedIpPayload {
ip: Ipv4Addr::new(192, 168, 1, 100),
reason: "port scan".into(),
duration_secs: 600,
confidence: 85,
};
assert!(mgr.receive_blocked_ip(peer, &high).is_some());
// Low confidence — rejected
let low = BlockedIpPayload {
ip: Ipv4Addr::new(192, 168, 1, 200),
reason: "maybe scan".into(),
duration_secs: 60,
confidence: 30,
};
assert!(mgr.receive_blocked_ip(peer, &low).is_none());
}
#[test]
fn make_hello() {
let mgr = PeerManager::new("node-42".into(), test_psk());
let hello = mgr.make_hello(100);
assert_eq!(hello.node_id, "node-42");
assert_eq!(hello.blocked_count, 100);
}
#[test]
fn prune_stale_peers() {
let mut mgr = PeerManager::new("test".into(), test_psk());
mgr.add_peer("10.0.0.1:9471".parse().unwrap());
mgr.add_peer("10.0.0.2:9471".parse().unwrap());
assert_eq!(mgr.peer_count(), 2);
// Stale after 0 seconds = prune all
mgr.prune_stale_peers(Duration::from_secs(0));
assert_eq!(mgr.peer_count(), 0);
}
}

View file

@ -0,0 +1,246 @@
//! Wire protocol for Blackwall peer-to-peer threat intelligence exchange.
//!
//! Binary protocol with HMAC-SHA256 authentication:
//! - Header: magic(4) + type(1) + payload_len(4) + hmac(32)
//! - Payload: type-specific JSON data
#![allow(dead_code)]
//!
//! The HMAC covers: magic + type + payload_len + payload (everything except
//! the HMAC field itself). A pre-shared key (PSK) must be configured on all
//! peers; connections without valid HMAC are rejected immediately.
use ring::hmac;
use serde::{Deserialize, Serialize};
use std::net::Ipv4Addr;
/// Protocol magic bytes: "BWL\x01"
pub const PROTOCOL_MAGIC: [u8; 4] = [0x42, 0x57, 0x4C, 0x01];
/// Size of the HMAC-SHA256 tag appended to the header.
pub const HMAC_SIZE: usize = 32;
/// Total header size: magic(4) + type(1) + payload_len(4) + hmac(32) = 41.
pub const HEADER_SIZE: usize = 4 + 1 + 4 + HMAC_SIZE;
/// Message types exchanged between peers.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageType {
/// Announce presence to peers
Hello = 0x01,
/// Share a blocked IP
BlockedIp = 0x02,
/// Share a JA4 fingerprint observation
Ja4Observation = 0x03,
/// Heartbeat / keepalive
Heartbeat = 0x04,
/// Request current threat list
SyncRequest = 0x05,
/// Response with threat entries
SyncResponse = 0x06,
}
impl MessageType {
/// Convert from u8 to MessageType.
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0x01 => Some(Self::Hello),
0x02 => Some(Self::BlockedIp),
0x03 => Some(Self::Ja4Observation),
0x04 => Some(Self::Heartbeat),
0x05 => Some(Self::SyncRequest),
0x06 => Some(Self::SyncResponse),
_ => None,
}
}
}
/// Hello message payload — node introduces itself.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloPayload {
/// Node identifier (hostname or UUID)
pub node_id: String,
/// Node version
pub version: String,
/// Number of currently blocked IPs
pub blocked_count: u32,
}
/// Blocked IP notification payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockedIpPayload {
/// The blocked IP address
pub ip: Ipv4Addr,
/// Reason for blocking
pub reason: String,
/// Block duration in seconds (0 = permanent)
pub duration_secs: u32,
/// Confidence score (0-100)
pub confidence: u8,
}
/// JA4 fingerprint observation payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ja4Payload {
/// Source IP that sent the TLS ClientHello
pub src_ip: Ipv4Addr,
/// JA4 fingerprint string
pub fingerprint: String,
/// Classification: "malicious", "benign", "unknown"
pub classification: String,
}
/// Encode a message to bytes with HMAC-SHA256 authentication.
///
/// Wire format: magic(4) + type(1) + payload_len(4) + hmac(32) + payload
/// HMAC covers: magic + type + payload_len + payload.
pub fn encode_message(msg_type: MessageType, payload: &[u8], key: &hmac::Key) -> Vec<u8> {
let len = payload.len() as u32;
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&PROTOCOL_MAGIC);
buf.push(msg_type as u8);
buf.extend_from_slice(&len.to_le_bytes());
// Compute HMAC over header fields + payload
let mut signing_ctx = hmac::Context::with_key(key);
signing_ctx.update(&PROTOCOL_MAGIC);
signing_ctx.update(&[msg_type as u8]);
signing_ctx.update(&len.to_le_bytes());
signing_ctx.update(payload);
let tag = signing_ctx.sign();
buf.extend_from_slice(tag.as_ref());
buf.extend_from_slice(payload);
buf
}
/// Decode a message header from bytes. Returns (type, payload_length) if valid.
///
/// IMPORTANT: This only validates the header structure and magic bytes.
/// Call `verify_hmac()` after reading the full payload to authenticate.
pub fn decode_header(data: &[u8]) -> Option<(MessageType, usize)> {
if data.len() < HEADER_SIZE {
return None;
}
if data[..4] != PROTOCOL_MAGIC {
return None;
}
let msg_type = MessageType::from_u8(data[4])?;
let payload_len = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
Some((msg_type, payload_len))
}
/// Extract the HMAC tag from a decoded header.
pub fn extract_hmac(header: &[u8]) -> Option<&[u8]> {
if header.len() < HEADER_SIZE {
return None;
}
Some(&header[9..9 + HMAC_SIZE])
}
/// Verify HMAC-SHA256 over header fields + payload.
///
/// Returns true if the HMAC is valid, false otherwise.
pub fn verify_hmac(header: &[u8], payload: &[u8], key: &hmac::Key) -> bool {
if header.len() < HEADER_SIZE {
return false;
}
let tag = &header[9..9 + HMAC_SIZE];
// Reconstruct signed data: magic + type + payload_len + payload
let mut verify_data = Vec::with_capacity(9 + payload.len());
verify_data.extend_from_slice(&header[..9]); // magic + type + payload_len
verify_data.extend_from_slice(payload);
hmac::verify(key, &verify_data, tag).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> hmac::Key {
hmac::Key::new(hmac::HMAC_SHA256, b"test-secret-key-for-blackwall")
}
#[test]
fn roundtrip_message() {
let key = test_key();
let payload = b"test data";
let encoded = encode_message(MessageType::Heartbeat, payload, &key);
let (msg_type, len) = decode_header(&encoded).expect("decode header");
assert_eq!(msg_type, MessageType::Heartbeat);
assert_eq!(len, payload.len());
assert_eq!(&encoded[HEADER_SIZE..], payload);
assert!(verify_hmac(&encoded[..HEADER_SIZE], payload, &key));
}
#[test]
fn invalid_magic_rejected() {
let key = test_key();
let mut data = encode_message(MessageType::Hello, b"hi", &key);
data[0] = 0xFF; // Corrupt magic
assert!(decode_header(&data).is_none());
}
#[test]
fn too_short_rejected() {
assert!(decode_header(&[0; 5]).is_none());
assert!(decode_header(&[0; 40]).is_none()); // < HEADER_SIZE (41)
}
#[test]
fn hmac_tamper_detected() {
let key = test_key();
let mut encoded = encode_message(MessageType::BlockedIp, b"payload", &key);
// Tamper with payload
if let Some(last) = encoded.last_mut() {
*last ^= 0xFF;
}
let (_, len) = decode_header(&encoded).expect("header still valid");
let payload = &encoded[HEADER_SIZE..HEADER_SIZE + len];
assert!(!verify_hmac(&encoded[..HEADER_SIZE], payload, &key));
}
#[test]
fn wrong_key_rejected() {
let key1 = test_key();
let key2 = hmac::Key::new(hmac::HMAC_SHA256, b"wrong-key");
let encoded = encode_message(MessageType::Hello, b"data", &key1);
let (_, len) = decode_header(&encoded).expect("decode");
let payload = &encoded[HEADER_SIZE..HEADER_SIZE + len];
assert!(!verify_hmac(&encoded[..HEADER_SIZE], payload, &key2));
}
#[test]
fn all_message_types() {
for byte in 0x01..=0x06 {
assert!(MessageType::from_u8(byte).is_some());
}
assert!(MessageType::from_u8(0x00).is_none());
assert!(MessageType::from_u8(0xFF).is_none());
}
#[test]
fn hello_payload_serialization() {
let hello = HelloPayload {
node_id: "node-1".into(),
version: "0.1.0".into(),
blocked_count: 42,
};
let json = serde_json::to_vec(&hello).unwrap();
let decoded: HelloPayload = serde_json::from_slice(&json).unwrap();
assert_eq!(decoded.node_id, "node-1");
assert_eq!(decoded.blocked_count, 42);
}
#[test]
fn blocked_ip_payload_serialization() {
let blocked = BlockedIpPayload {
ip: Ipv4Addr::new(192, 168, 1, 100),
reason: "port scan".into(),
duration_secs: 600,
confidence: 85,
};
let json = serde_json::to_vec(&blocked).unwrap();
let decoded: BlockedIpPayload = serde_json::from_slice(&json).unwrap();
assert_eq!(decoded.ip, Ipv4Addr::new(192, 168, 1, 100));
assert_eq!(decoded.confidence, 85);
}
}

214
blackwall/src/dpi/dns.rs Executable file
View file

@ -0,0 +1,214 @@
//! DNS query/response dissector.
//!
//! Parses DNS wire format from raw bytes.
//! Extracts query name, type, and detects tunneling indicators.
/// Extracted DNS query metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnsInfo {
/// Transaction ID
pub id: u16,
/// Whether this is a query (false) or response (true)
pub is_response: bool,
/// Number of questions
pub question_count: u16,
/// First query domain name (if present)
pub query_name: Option<String>,
/// First query type (A=1, AAAA=28, MX=15, TXT=16, CNAME=5)
pub query_type: Option<u16>,
}
/// Maximum DNS name length to parse (prevents excessive processing).
const MAX_DNS_NAME_LEN: usize = 253;
/// Maximum label count to prevent infinite loops on malformed packets.
const MAX_LABELS: usize = 128;
/// Parse a DNS query/response from raw bytes (UDP payload).
pub fn parse_query(data: &[u8]) -> Option<DnsInfo> {
// DNS header is 12 bytes minimum
if data.len() < 12 {
return None;
}
let id = u16::from_be_bytes([data[0], data[1]]);
let flags = u16::from_be_bytes([data[2], data[3]]);
let is_response = (flags & 0x8000) != 0;
let question_count = u16::from_be_bytes([data[4], data[5]]);
// Sanity: must have at least 1 question
if question_count == 0 || question_count > 256 {
return None;
}
// Parse first question name starting at offset 12
let (query_name, offset) = parse_dns_name(data, 12)?;
// Parse query type (2 bytes after name)
let query_type = if offset + 2 <= data.len() {
Some(u16::from_be_bytes([data[offset], data[offset + 1]]))
} else {
None
};
Some(DnsInfo {
id,
is_response,
question_count,
query_name: Some(query_name),
query_type,
})
}
/// Parse a DNS domain name from wire format. Returns (name, bytes_consumed_offset).
fn parse_dns_name(data: &[u8], start: usize) -> Option<(String, usize)> {
let mut name = String::new();
let mut pos = start;
let mut labels = 0;
loop {
if pos >= data.len() || labels >= MAX_LABELS {
return None;
}
let label_len = data[pos] as usize;
if label_len == 0 {
pos += 1;
break;
}
// Compression pointer (0xC0 prefix) — not following for simplicity
if label_len & 0xC0 == 0xC0 {
pos += 2;
break;
}
if label_len > 63 {
return None; // Invalid label length
}
pos += 1;
if pos + label_len > data.len() {
return None;
}
if !name.is_empty() {
name.push('.');
}
let label = std::str::from_utf8(&data[pos..pos + label_len]).ok()?;
name.push_str(label);
if name.len() > MAX_DNS_NAME_LEN {
return None;
}
pos += label_len;
labels += 1;
}
if name.is_empty() {
return None;
}
Some((name, pos))
}
/// Heuristic: check if a DNS query name looks like DNS tunneling.
/// High entropy names, very long labels, and many subdomains are suspicious.
pub fn is_tunneling_suspect(name: &str) -> bool {
// Long overall name
if name.len() > 60 {
return true;
}
let labels: Vec<&str> = name.split('.').collect();
// Many subdomain levels
if labels.len() > 6 {
return true;
}
// Any individual label is unusually long (>30 chars suggests encoded data)
for label in &labels {
if label.len() > 30 {
return true;
}
}
// High ratio of digits/hex chars in labels (encoded payload)
let total_chars: usize = labels.iter().take(labels.len().saturating_sub(2)).map(|l| l.len()).sum();
if total_chars > 10 {
let hex_chars: usize = labels
.iter()
.take(labels.len().saturating_sub(2))
.flat_map(|l| l.chars())
.filter(|c| c.is_ascii_hexdigit() && !c.is_ascii_alphabetic())
.count();
if hex_chars * 3 > total_chars {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn build_dns_query(name: &str, qtype: u16) -> Vec<u8> {
let mut pkt = vec![
0x12, 0x34, // Transaction ID
0x01, 0x00, // Flags: standard query
0x00, 0x01, // Questions: 1
0x00, 0x00, // Answers: 0
0x00, 0x00, // Authority: 0
0x00, 0x00, // Additional: 0
];
// Encode name
for label in name.split('.') {
pkt.push(label.len() as u8);
pkt.extend_from_slice(label.as_bytes());
}
pkt.push(0); // Root terminator
pkt.extend_from_slice(&qtype.to_be_bytes());
pkt.extend_from_slice(&1u16.to_be_bytes()); // Class IN
pkt
}
#[test]
fn parse_simple_a_query() {
let pkt = build_dns_query("example.com", 1);
let info = parse_query(&pkt).unwrap();
assert_eq!(info.id, 0x1234);
assert!(!info.is_response);
assert_eq!(info.question_count, 1);
assert_eq!(info.query_name, Some("example.com".into()));
assert_eq!(info.query_type, Some(1)); // A record
}
#[test]
fn parse_txt_query() {
let pkt = build_dns_query("tunnel.evil.com", 16);
let info = parse_query(&pkt).unwrap();
assert_eq!(info.query_name, Some("tunnel.evil.com".into()));
assert_eq!(info.query_type, Some(16)); // TXT record
}
#[test]
fn reject_too_short() {
assert!(parse_query(&[0; 6]).is_none());
}
#[test]
fn tunneling_detection() {
assert!(!is_tunneling_suspect("google.com"));
assert!(!is_tunneling_suspect("www.example.com"));
assert!(is_tunneling_suspect(
"aGVsbG8gd29ybGQgdGhpcyBpcyBlbmNvZGVk.tunnel.evil.com"
));
assert!(is_tunneling_suspect(
"a.b.c.d.e.f.g.evil.com"
));
}
}

174
blackwall/src/dpi/http.rs Executable file
View file

@ -0,0 +1,174 @@
//! HTTP request/response dissector.
//!
//! Parses HTTP/1.x request lines and headers from raw bytes.
//! Extracts method, path, Host header, User-Agent, and Content-Type.
/// Extracted HTTP request metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpInfo {
/// HTTP method (GET, POST, PUT, etc.)
pub method: String,
/// Request path
pub path: String,
/// HTTP version (e.g., "1.1", "1.0")
pub version: String,
/// Host header value
pub host: Option<String>,
/// User-Agent header value
pub user_agent: Option<String>,
/// Content-Type header value
pub content_type: Option<String>,
}
/// Known suspicious paths that scanners frequently probe.
const SUSPICIOUS_PATHS: &[&str] = &[
"/wp-login.php",
"/wp-admin",
"/xmlrpc.php",
"/phpmyadmin",
"/admin",
"/administrator",
"/.env",
"/.git/config",
"/config.php",
"/shell",
"/cmd",
"/eval",
"/actuator",
"/solr",
"/console",
"/manager/html",
"/cgi-bin/",
"/../",
];
/// Known malicious User-Agent patterns.
const SUSPICIOUS_USER_AGENTS: &[&str] = &[
"sqlmap",
"nikto",
"nmap",
"masscan",
"zgrab",
"gobuster",
"dirbuster",
"wpscan",
"nuclei",
"httpx",
"curl/",
"python-requests",
"go-http-client",
];
/// Parse an HTTP request from raw bytes.
pub fn parse_request(data: &[u8]) -> Option<HttpInfo> {
// HTTP requests start with a method followed by space
let text = std::str::from_utf8(data).ok()?;
// Find the request line (first line)
let request_line = text.lines().next()?;
let mut parts = request_line.splitn(3, ' ');
let method = parts.next()?;
// Validate method
if !matches!(
method,
"GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH" | "CONNECT" | "TRACE"
) {
return None;
}
let path = parts.next().unwrap_or("/");
let version_str = parts.next().unwrap_or("HTTP/1.1");
let version = version_str.strip_prefix("HTTP/").unwrap_or("1.1");
// Parse headers
let mut host = None;
let mut user_agent = None;
let mut content_type = None;
for line in text.lines().skip(1) {
if line.is_empty() {
break; // End of headers
}
if let Some((name, value)) = line.split_once(':') {
let name_lower = name.trim().to_lowercase();
let value = value.trim();
match name_lower.as_str() {
"host" => host = Some(value.to_string()),
"user-agent" => user_agent = Some(value.to_string()),
"content-type" => content_type = Some(value.to_string()),
_ => {}
}
}
}
Some(HttpInfo {
method: method.to_string(),
path: path.to_string(),
version: version.to_string(),
host,
user_agent,
content_type,
})
}
/// Check if the request path is suspicious (known scanner targets).
pub fn is_suspicious_path(path: &str) -> bool {
let lower = path.to_lowercase();
SUSPICIOUS_PATHS.iter().any(|p| lower.contains(p))
}
/// Check if the User-Agent matches known scanning tools.
pub fn is_suspicious_user_agent(ua: &str) -> bool {
let lower = ua.to_lowercase();
SUSPICIOUS_USER_AGENTS.iter().any(|p| lower.contains(p))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_get() {
let data = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
let info = parse_request(data).unwrap();
assert_eq!(info.method, "GET");
assert_eq!(info.path, "/");
assert_eq!(info.version, "1.1");
assert_eq!(info.host, Some("example.com".into()));
}
#[test]
fn parse_post_with_headers() {
let data = b"POST /api/login HTTP/1.1\r\nHost: app.local\r\n\
User-Agent: Mozilla/5.0\r\nContent-Type: application/json\r\n\r\n";
let info = parse_request(data).unwrap();
assert_eq!(info.method, "POST");
assert_eq!(info.path, "/api/login");
assert_eq!(info.user_agent, Some("Mozilla/5.0".into()));
assert_eq!(info.content_type, Some("application/json".into()));
}
#[test]
fn reject_non_http() {
assert!(parse_request(b"SSH-2.0-OpenSSH\r\n").is_none());
assert!(parse_request(b"\x00\x01\x02").is_none());
}
#[test]
fn suspicious_path_detection() {
assert!(is_suspicious_path("/wp-login.php"));
assert!(is_suspicious_path("/foo/../etc/passwd"));
assert!(is_suspicious_path("/.env"));
assert!(!is_suspicious_path("/index.html"));
assert!(!is_suspicious_path("/api/v1/users"));
}
#[test]
fn suspicious_user_agent_detection() {
assert!(is_suspicious_user_agent("sqlmap/1.7.2"));
assert!(is_suspicious_user_agent("Nikto/2.1.6"));
assert!(is_suspicious_user_agent("python-requests/2.28.0"));
assert!(!is_suspicious_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"));
}
}

73
blackwall/src/dpi/mod.rs Executable file
View file

@ -0,0 +1,73 @@
//! Deep Packet Inspection (DPI) dissectors for protocol-level analysis.
//!
//! Operates on raw connection bytes captured from network streams.
//! Dissectors extract protocol metadata for threat classification.
#[allow(dead_code)]
pub mod dns;
#[allow(dead_code)]
pub mod http;
#[allow(dead_code)]
pub mod ssh;
/// Protocol identified by DPI analysis.
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub enum DetectedProtocol {
Http(http::HttpInfo),
Dns(dns::DnsInfo),
Ssh(ssh::SshInfo),
Unknown,
}
/// Attempt to identify the protocol from the first bytes of a connection.
#[allow(dead_code)]
pub fn identify_protocol(data: &[u8]) -> DetectedProtocol {
// Try HTTP first (most common on redirected traffic)
if let Some(info) = http::parse_request(data) {
return DetectedProtocol::Http(info);
}
// Try SSH banner
if let Some(info) = ssh::parse_banner(data) {
return DetectedProtocol::Ssh(info);
}
// Try DNS (UDP payload)
if let Some(info) = dns::parse_query(data) {
return DetectedProtocol::Dns(info);
}
DetectedProtocol::Unknown
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identify_http_get() {
let data = b"GET /admin HTTP/1.1\r\nHost: example.com\r\n\r\n";
match identify_protocol(data) {
DetectedProtocol::Http(info) => {
assert_eq!(info.method, "GET");
assert_eq!(info.path, "/admin");
}
other => panic!("expected Http, got {:?}", other),
}
}
#[test]
fn identify_ssh_banner() {
let data = b"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5\r\n";
match identify_protocol(data) {
DetectedProtocol::Ssh(info) => {
assert!(info.version.contains("OpenSSH"));
}
other => panic!("expected Ssh, got {:?}", other),
}
}
#[test]
fn identify_unknown() {
let data = b"\x00\x01\x02\x03random binary";
assert_eq!(identify_protocol(data), DetectedProtocol::Unknown);
}
}

103
blackwall/src/dpi/ssh.rs Executable file
View file

@ -0,0 +1,103 @@
//! SSH banner/version dissector.
//!
//! Parses SSH protocol version exchange strings.
//! Extracts software version and detects known scanning tools.
/// Extracted SSH banner metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshInfo {
/// Full version string (e.g., "SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5")
pub version: String,
/// Protocol version ("2.0" or "1.99")
pub protocol: String,
/// Software identifier (e.g., "OpenSSH_9.6p1")
pub software: String,
/// Optional comment/OS info
pub comment: Option<String>,
}
/// Known SSH scanning/attack tool identifiers.
const SUSPICIOUS_SSH_SOFTWARE: &[&str] = &[
"libssh", // Frequently used by bots
"paramiko", // Python SSH library, common in automated attacks
"putty", // PuTTY — sometimes spoofed
"go", // Go SSH libraries (automated scanners)
"asyncssh", // Python async SSH
"nmap", // Nmap SSH scanning
"dropbear_2012", // Very old, likely compromised device
"dropbear_2014",
"sshlibrary",
"russh",
];
/// Parse an SSH banner from raw connection bytes.
pub fn parse_banner(data: &[u8]) -> Option<SshInfo> {
let text = std::str::from_utf8(data).ok()?;
let line = text.lines().next()?;
// SSH banner format: SSH-protoversion-softwareversion [SP comments]
if !line.starts_with("SSH-") {
return None;
}
let banner = line.strip_prefix("SSH-")?;
// Split into protocol-softwareversion and optional comment
let (proto_sw, comment) = match banner.split_once(' ') {
Some((ps, c)) => (ps, Some(c.trim().to_string())),
None => (banner.trim_end_matches('\r'), None),
};
// Split protocol and software
let (protocol, software) = proto_sw.split_once('-')?;
Some(SshInfo {
version: line.to_string(),
protocol: protocol.to_string(),
software: software.to_string(),
comment,
})
}
/// Check if the SSH software banner matches known scanning/attack tools.
pub fn is_suspicious_software(software: &str) -> bool {
let lower = software.to_lowercase();
SUSPICIOUS_SSH_SOFTWARE.iter().any(|s| lower.contains(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_openssh_banner() {
let data = b"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5\r\n";
let info = parse_banner(data).unwrap();
assert_eq!(info.protocol, "2.0");
assert_eq!(info.software, "OpenSSH_9.6p1");
assert_eq!(info.comment, Some("Ubuntu-3ubuntu13.5".into()));
}
#[test]
fn parse_no_comment() {
let data = b"SSH-2.0-dropbear_2022.82\r\n";
let info = parse_banner(data).unwrap();
assert_eq!(info.protocol, "2.0");
assert_eq!(info.software, "dropbear_2022.82");
assert!(info.comment.is_none());
}
#[test]
fn reject_non_ssh() {
assert!(parse_banner(b"HTTP/1.1 200 OK\r\n").is_none());
assert!(parse_banner(b"\x00\x01\x02").is_none());
}
#[test]
fn suspicious_software_detection() {
assert!(is_suspicious_software("libssh-0.9.6"));
assert!(is_suspicious_software("Paramiko_3.4.0"));
assert!(!is_suspicious_software("OpenSSH_9.6p1"));
assert!(!is_suspicious_software("dropbear_2022.82"));
}
}

113
blackwall/src/events.rs Executable file
View file

@ -0,0 +1,113 @@
use anyhow::Result;
use aya::maps::{MapData, RingBuf};
use common::{DpiEvent, EgressEvent, PacketEvent, TlsComponentsEvent};
use crossbeam_queue::SegQueue;
use std::os::fd::AsRawFd;
use std::sync::Arc;
use tokio::io::unix::AsyncFd;
/// Asynchronously consume PacketEvents from the eBPF RingBuf and push them
/// into a lock-free queue for downstream processing.
pub async fn consume_events(
ring_buf: RingBuf<MapData>,
event_tx: Arc<SegQueue<PacketEvent>>,
) -> Result<()> {
let mut ring_buf = ring_buf;
let async_fd = AsyncFd::new(ring_buf.as_raw_fd())?;
loop {
// Wait for readability (epoll-based, no busy-spin)
let mut guard = async_fd.readable().await?;
// Drain all available events
while let Some(event_data) = ring_buf.next() {
if event_data.len() < core::mem::size_of::<PacketEvent>() {
continue;
}
// SAFETY: PacketEvent is #[repr(C)] with known layout, Pod-safe.
// eBPF wrote exactly sizeof(PacketEvent) bytes via reserve/submit.
let event: &PacketEvent = unsafe { &*(event_data.as_ptr() as *const PacketEvent) };
event_tx.push(*event);
}
guard.clear_ready();
}
}
/// Asynchronously consume TlsComponentsEvents from the eBPF TLS_EVENTS RingBuf
/// and push them into a lock-free queue for JA4 fingerprint assembly.
pub async fn consume_tls_events(
ring_buf: RingBuf<MapData>,
tls_tx: Arc<SegQueue<TlsComponentsEvent>>,
) -> Result<()> {
let mut ring_buf = ring_buf;
let async_fd = AsyncFd::new(ring_buf.as_raw_fd())?;
loop {
let mut guard = async_fd.readable().await?;
while let Some(event_data) = ring_buf.next() {
if event_data.len() < core::mem::size_of::<TlsComponentsEvent>() {
continue;
}
// SAFETY: TlsComponentsEvent is #[repr(C)] Pod-safe, written by eBPF reserve/submit.
let event: &TlsComponentsEvent =
unsafe { &*(event_data.as_ptr() as *const TlsComponentsEvent) };
tls_tx.push(*event);
}
guard.clear_ready();
}
}
/// Asynchronously consume EgressEvents from the eBPF EGRESS_EVENTS RingBuf
/// and push them into a lock-free queue for outbound traffic analysis.
pub async fn consume_egress_events(
ring_buf: RingBuf<MapData>,
egress_tx: Arc<SegQueue<EgressEvent>>,
) -> Result<()> {
let mut ring_buf = ring_buf;
let async_fd = AsyncFd::new(ring_buf.as_raw_fd())?;
loop {
let mut guard = async_fd.readable().await?;
while let Some(event_data) = ring_buf.next() {
if event_data.len() < core::mem::size_of::<EgressEvent>() {
continue;
}
// SAFETY: EgressEvent is #[repr(C)] Pod-safe, written by eBPF reserve/submit.
let event: &EgressEvent =
unsafe { &*(event_data.as_ptr() as *const EgressEvent) };
egress_tx.push(*event);
}
guard.clear_ready();
}
}
/// Asynchronously consume DpiEvents from the eBPF DPI_EVENTS RingBuf
/// and push them into a lock-free queue for protocol-level analysis.
pub async fn consume_dpi_events(
ring_buf: RingBuf<MapData>,
dpi_tx: Arc<SegQueue<DpiEvent>>,
) -> Result<()> {
let mut ring_buf = ring_buf;
let async_fd = AsyncFd::new(ring_buf.as_raw_fd())?;
loop {
let mut guard = async_fd.readable().await?;
while let Some(event_data) = ring_buf.next() {
if event_data.len() < core::mem::size_of::<DpiEvent>() {
continue;
}
// SAFETY: DpiEvent is #[repr(C)] Pod-safe, written by eBPF reserve/submit.
let event: &DpiEvent =
unsafe { &*(event_data.as_ptr() as *const DpiEvent) };
dpi_tx.push(*event);
}
guard.clear_ready();
}
}

230
blackwall/src/feeds.rs Executable file
View file

@ -0,0 +1,230 @@
//! Threat feed fetcher: downloads IP blocklists and updates eBPF maps.
//!
//! Supports plain-text feeds (one IP per line, # comments).
//! Popular sources: Firehol level1, abuse.ch feodo, Spamhaus DROP.
//! Handles both single IPs and CIDR ranges (e.g., `10.0.0.0/8`).
use anyhow::{Context, Result};
use http_body_util::{BodyExt, Empty, Limited};
use hyper::body::Bytes;
use hyper::Request;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use std::net::Ipv4Addr;
use std::time::Duration;
/// Maximum IPs to ingest from a single feed (prevents memory exhaustion).
const MAX_IPS_PER_FEED: usize = 50_000;
/// HTTP request timeout per feed.
const FEED_TIMEOUT_SECS: u64 = 30;
/// Maximum response body size (10 MB) — prevents memory exhaustion from rogue feeds.
const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
/// A single entry parsed from a threat feed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FeedEntry {
/// Single IP address.
Single(Ipv4Addr),
/// CIDR range (network address + prefix length).
Cidr(Ipv4Addr, u8),
}
/// A configured threat feed source.
#[derive(Debug, Clone)]
pub struct FeedSource {
/// Human-readable name for logging.
pub name: String,
/// URL to fetch (must return text/plain with one IP per line).
pub url: String,
/// Block duration in seconds (0 = permanent until next refresh).
pub block_duration_secs: u32,
}
/// Fetch a single feed and return parsed entries (single IPs or CIDR ranges).
pub async fn fetch_feed(source: &FeedSource) -> Result<Vec<FeedEntry>> {
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_webpki_roots()
.https_or_http()
.enable_http1()
.build();
let client = Client::builder(TokioExecutor::new()).build(https);
let req = Request::get(&source.url)
.header("User-Agent", "Blackwall/0.1")
.body(Empty::<Bytes>::new())
.context("invalid feed URL")?;
let resp = tokio::time::timeout(
Duration::from_secs(FEED_TIMEOUT_SECS),
client.request(req),
)
.await
.context("feed request timed out")?
.context("feed HTTP request failed")?;
if !resp.status().is_success() {
anyhow::bail!(
"feed {} returned HTTP {}",
source.name,
resp.status()
);
}
let body_bytes = Limited::new(resp.into_body(), MAX_BODY_BYTES)
.collect()
.await
.map_err(|e| anyhow::anyhow!("failed to read feed body (possibly exceeded 10MB limit): {}", e))?
.to_bytes();
let body = String::from_utf8_lossy(&body_bytes);
let entries = parse_feed_body(&body);
if entries.len() >= MAX_IPS_PER_FEED {
tracing::warn!(
feed = %source.name,
max = MAX_IPS_PER_FEED,
"feed truncated at max entries"
);
}
Ok(entries)
}
/// Parse feed body text into entries (reusable for testing).
fn parse_feed_body(body: &str) -> Vec<FeedEntry> {
let mut entries = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
// Skip comments and empty lines
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
// Some feeds have "IP<tab>info" or "IP # comment" format
let ip_str = trimmed.split_whitespace().next().unwrap_or("");
if let Some(entry) = parse_ip_or_cidr(ip_str) {
entries.push(entry);
if entries.len() >= MAX_IPS_PER_FEED {
break;
}
}
}
entries
}
/// Parse a single token as either `IP/prefix` (CIDR) or plain IP.
fn parse_ip_or_cidr(s: &str) -> Option<FeedEntry> {
if let Some((ip_part, prefix_part)) = s.split_once('/') {
let ip: Ipv4Addr = ip_part.parse().ok()?;
let prefix: u8 = prefix_part.parse().ok()?;
if prefix > 32 {
return None;
}
Some(FeedEntry::Cidr(ip, prefix))
} else {
let ip: Ipv4Addr = s.parse().ok()?;
Some(FeedEntry::Single(ip))
}
}
/// Fetch all configured feeds and return combined unique entries with block durations.
pub async fn fetch_all_feeds(sources: &[FeedSource]) -> Vec<(FeedEntry, u32)> {
let mut all_entries: Vec<(FeedEntry, u32)> = Vec::new();
let mut seen_ips = std::collections::HashSet::new();
let mut seen_cidrs = std::collections::HashSet::new();
for source in sources {
match fetch_feed(source).await {
Ok(entries) => {
let count = entries.len();
for entry in entries {
let is_new = match &entry {
FeedEntry::Single(ip) => seen_ips.insert(*ip),
FeedEntry::Cidr(ip, prefix) => seen_cidrs.insert((*ip, *prefix)),
};
if is_new {
all_entries.push((entry, source.block_duration_secs));
}
}
tracing::info!(
feed = %source.name,
new_entries = count,
total = all_entries.len(),
"feed fetched successfully"
);
}
Err(e) => {
tracing::warn!(
feed = %source.name,
error = %e,
"feed fetch failed — skipping"
);
}
}
}
all_entries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_plain_ip_list() {
let body = "# Comment line\n\
192.168.1.1\n\
10.0.0.1\n\
\n\
; Another comment\n\
172.16.0.1\t# with trailing comment\n\
invalid-not-ip\n\
256.1.1.1\n";
let entries = parse_feed_body(body);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0], FeedEntry::Single(Ipv4Addr::new(192, 168, 1, 1)));
assert_eq!(entries[1], FeedEntry::Single(Ipv4Addr::new(10, 0, 0, 1)));
assert_eq!(entries[2], FeedEntry::Single(Ipv4Addr::new(172, 16, 0, 1)));
}
#[test]
fn parse_cidr_preserves_prefix() {
let entries = parse_feed_body("10.0.0.0/8\n192.168.0.0/16\n");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0], FeedEntry::Cidr(Ipv4Addr::new(10, 0, 0, 0), 8));
assert_eq!(entries[1], FeedEntry::Cidr(Ipv4Addr::new(192, 168, 0, 0), 16));
}
#[test]
fn parse_mixed_ips_and_cidrs() {
let body = "# Spamhaus DROP\n\
1.2.3.4\n\
10.0.0.0/8\n\
5.6.7.8\n\
192.168.0.0/24\n";
let entries = parse_feed_body(body);
assert_eq!(entries.len(), 4);
assert_eq!(entries[0], FeedEntry::Single(Ipv4Addr::new(1, 2, 3, 4)));
assert_eq!(entries[1], FeedEntry::Cidr(Ipv4Addr::new(10, 0, 0, 0), 8));
assert_eq!(entries[2], FeedEntry::Single(Ipv4Addr::new(5, 6, 7, 8)));
assert_eq!(entries[3], FeedEntry::Cidr(Ipv4Addr::new(192, 168, 0, 0), 24));
}
#[test]
fn parse_invalid_cidr_prefix_rejected() {
let entries = parse_feed_body("10.0.0.0/33\n");
assert!(entries.is_empty());
}
#[test]
fn feed_source_construction() {
let src = FeedSource {
name: "test".into(),
url: "http://example.com/ips.txt".into(),
block_duration_secs: 3600,
};
assert_eq!(src.block_duration_secs, 3600);
}
}

229
blackwall/src/firewall.rs Executable file
View file

@ -0,0 +1,229 @@
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::io::Write;
use std::net::Ipv4Addr;
use std::process::{Command, Stdio};
use std::time::Instant;
/// Max iptables fork/exec calls per second to prevent resource exhaustion
/// under DDoS. Excess IPs are queued and applied via `iptables-restore` batch.
const MAX_INDIVIDUAL_OPS_PER_SEC: u32 = 10;
/// Manages iptables DNAT rules to redirect attacker traffic to the tarpit.
///
/// # Deprecation
/// **V2.0**: eBPF native DNAT via TARPIT_TARGET map replaces iptables.
/// This manager is retained as a legacy fallback for systems where XDP
/// header rewriting is not available (e.g., HW offload without TC support).
/// New deployments should use eBPF DNAT exclusively.
///
/// # Performance
/// Individual `redirect_to_tarpit` calls use fork/exec (fine at low rate).
/// Under DDoS, callers should use `redirect_batch` or call `flush_pending`
/// periodically — these use `iptables-restore --noflush` (single fork for N rules).
#[deprecated(note = "V2.0: eBPF native DNAT replaces iptables. Retained as legacy fallback.")]
pub struct FirewallManager {
active_redirects: HashSet<Ipv4Addr>,
/// IPs awaiting batch insertion (rate-limit overflow).
pending_redirects: Vec<Ipv4Addr>,
tarpit_port: u16,
/// Rate limiter: ops performed in the current second.
ops_this_window: u32,
/// Start of the current rate-limit window.
window_start: Instant,
}
impl FirewallManager {
/// Create a new FirewallManager targeting the given tarpit port.
pub fn new(tarpit_port: u16) -> Self {
Self {
active_redirects: HashSet::new(),
pending_redirects: Vec::new(),
tarpit_port,
ops_this_window: 0,
window_start: Instant::now(),
}
}
/// Reset the rate-limit window if a second has elapsed.
fn maybe_reset_window(&mut self) {
if self.window_start.elapsed().as_secs() >= 1 {
self.ops_this_window = 0;
self.window_start = Instant::now();
}
}
/// Add a DNAT rule to redirect all TCP traffic from `ip` to the tarpit.
///
/// If the per-second rate limit is exceeded, the IP is queued for batch
/// insertion via `flush_pending()`.
pub fn redirect_to_tarpit(&mut self, ip: Ipv4Addr) -> Result<()> {
if self.active_redirects.contains(&ip) {
return Ok(());
}
self.maybe_reset_window();
if self.ops_this_window >= MAX_INDIVIDUAL_OPS_PER_SEC {
// Rate limit hit — queue for batch insertion
if !self.pending_redirects.contains(&ip) {
self.pending_redirects.push(ip);
tracing::debug!(%ip, pending = self.pending_redirects.len(),
"DNAT queued (rate limit)");
}
return Ok(());
}
self.apply_single_redirect(ip)?;
self.ops_this_window += 1;
Ok(())
}
/// Apply a single iptables DNAT rule via fork/exec.
fn apply_single_redirect(&mut self, ip: Ipv4Addr) -> Result<()> {
let dest = format!("127.0.0.1:{}", self.tarpit_port);
let status = Command::new("iptables")
.args([
"-t", "nat", "-A", "PREROUTING",
"-s", &ip.to_string(),
"-p", "tcp",
"-j", "DNAT",
"--to-destination", &dest,
])
.status()
.context("failed to execute iptables")?;
if !status.success() {
anyhow::bail!("iptables returned non-zero status for redirect of {}", ip);
}
self.active_redirects.insert(ip);
tracing::info!(%ip, "iptables DNAT redirect added");
Ok(())
}
/// Batch-insert multiple DNAT rules in a single `iptables-restore` call.
///
/// Uses `--noflush` to append rules without clearing existing tables.
/// Single fork/exec for N rules — O(1) process creation vs O(N).
pub fn redirect_batch(&mut self, ips: &[Ipv4Addr]) -> Result<()> {
let new_ips: Vec<Ipv4Addr> = ips
.iter()
.copied()
.filter(|ip| !self.active_redirects.contains(ip))
.collect();
if new_ips.is_empty() {
return Ok(());
}
let dest = format!("127.0.0.1:{}", self.tarpit_port);
let mut rules = String::from("*nat\n");
for ip in &new_ips {
// PERF: single iptables-restore call for all IPs
rules.push_str(&format!(
"-A PREROUTING -s {} -p tcp -j DNAT --to-destination {}\n",
ip, dest
));
}
rules.push_str("COMMIT\n");
let mut child = Command::new("iptables-restore")
.arg("--noflush")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn iptables-restore")?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(rules.as_bytes())
.context("failed to write to iptables-restore stdin")?;
}
let output = child
.wait_with_output()
.context("iptables-restore failed")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("iptables-restore failed: {}", stderr);
}
for ip in &new_ips {
self.active_redirects.insert(*ip);
}
tracing::info!(count = new_ips.len(), "iptables DNAT batch redirect added");
Ok(())
}
/// Flush all pending (rate-limited) redirects via a single batch call.
///
/// Should be called periodically from the event loop (e.g. every 1s).
pub fn flush_pending(&mut self) -> Result<()> {
if self.pending_redirects.is_empty() {
return Ok(());
}
let pending = std::mem::take(&mut self.pending_redirects);
tracing::info!(count = pending.len(), "flushing pending DNAT redirects");
self.redirect_batch(&pending)
}
/// Remove the DNAT rule for a specific IP.
pub fn remove_redirect(&mut self, ip: Ipv4Addr) -> Result<()> {
if !self.active_redirects.contains(&ip) {
return Ok(());
}
let dest = format!("127.0.0.1:{}", self.tarpit_port);
let status = Command::new("iptables")
.args([
"-t", "nat", "-D", "PREROUTING",
"-s", &ip.to_string(),
"-p", "tcp",
"-j", "DNAT",
"--to-destination", &dest,
])
.status()
.context("failed to execute iptables")?;
if !status.success() {
tracing::warn!(%ip, "iptables rule removal returned non-zero");
}
self.active_redirects.remove(&ip);
Ok(())
}
/// Remove all active redirect rules. Called on graceful shutdown.
pub fn cleanup_all(&mut self) -> Result<()> {
let ips: Vec<Ipv4Addr> = self.active_redirects.iter().copied().collect();
for ip in ips {
if let Err(e) = self.remove_redirect(ip) {
tracing::warn!(%ip, "cleanup failed: {}", e);
}
}
Ok(())
}
/// Number of currently active DNAT redirects.
pub fn active_count(&self) -> usize {
self.active_redirects.len()
}
/// Number of pending (rate-limited) redirects awaiting flush.
pub fn pending_count(&self) -> usize {
self.pending_redirects.len()
}
}
impl Drop for FirewallManager {
fn drop(&mut self) {
if let Err(e) = self.cleanup_all() {
tracing::error!("firewall cleanup on drop failed: {}", e);
}
}
}

230
blackwall/src/ja4/assembler.rs Executable file
View file

@ -0,0 +1,230 @@
//! JA4 fingerprint assembler: converts raw TLS ClientHello components
//! into a JA4-format string.
//!
//! JA4 format (simplified):
//! `t{version}{sni_flag}{cipher_count}{ext_count}_{cipher_hash}_{ext_hash}`
//!
//! - version: TLS version code (12=TLS1.2, 13=TLS1.3)
//! - sni_flag: 'd' if SNI present, 'i' if absent
//! - cipher_count: 2-digit count of cipher suites (capped at 99)
//! - ext_count: 2-digit count of extensions (capped at 99)
//! - cipher_hash: first 12 chars of hex-encoded hash of sorted cipher suite IDs
//! - ext_hash: first 12 chars of hex-encoded hash of sorted extension IDs
use common::TlsComponentsEvent;
/// Assembled JA4 fingerprint with metadata.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Ja4Fingerprint {
/// Full JA4 string (e.g., "t13d1510_a0b1c2d3e4f5_f5e4d3c2b1a0")
pub fingerprint: String,
/// Source IP (network byte order)
pub src_ip: u32,
/// Destination IP (network byte order)
pub dst_ip: u32,
/// Source port
pub src_port: u16,
/// Destination port
pub dst_port: u16,
/// SNI hostname (if present)
pub sni: Option<String>,
}
/// Assembles JA4 fingerprints from eBPF TlsComponentsEvent.
pub struct Ja4Assembler;
impl Ja4Assembler {
/// Compute JA4 fingerprint from raw TLS ClientHello components.
pub fn assemble(event: &TlsComponentsEvent) -> Ja4Fingerprint {
let version = tls_version_code(event.tls_version);
let sni_flag = if event.has_sni != 0 { 'd' } else { 'i' };
let cipher_count = (event.cipher_count as u16).min(99);
let ext_count = (event.ext_count as u16).min(99);
// Sort and hash cipher suites
let mut ciphers: Vec<u16> = event.ciphers[..event.cipher_count as usize]
.iter()
.copied()
// GREASE values: 0x{0a,1a,2a,...,fa}0a — skip them
.filter(|&c| !is_grease(c))
.collect();
ciphers.sort_unstable();
let cipher_hash = truncated_hash(&ciphers);
// Sort and hash extensions
let mut extensions: Vec<u16> = event.extensions[..event.ext_count as usize]
.iter()
.copied()
.filter(|&e| !is_grease(e))
.collect();
extensions.sort_unstable();
let ext_hash = truncated_hash(&extensions);
// Build JA4 string
let fingerprint = format!(
"t{}{}{:02}{:02}_{}_{}",
version, sni_flag, cipher_count, ext_count, cipher_hash, ext_hash
);
// Extract SNI
let sni = if event.has_sni != 0 {
let sni_bytes = &event.sni[..];
let end = sni_bytes.iter().position(|&b| b == 0).unwrap_or(sni_bytes.len());
if end > 0 {
Some(String::from_utf8_lossy(&sni_bytes[..end]).into_owned())
} else {
None
}
} else {
None
};
Ja4Fingerprint {
fingerprint,
src_ip: event.src_ip,
dst_ip: event.dst_ip,
src_port: event.src_port,
dst_port: event.dst_port,
sni,
}
}
}
/// Map TLS version u16 to JA4 version code.
fn tls_version_code(version: u16) -> &'static str {
match version {
0x0304 => "13",
0x0303 => "12",
0x0302 => "11",
0x0301 => "10",
0x0300 => "s3",
_ => "00",
}
}
/// Check if a TLS value is a GREASE (Generate Random Extensions And Sustain Extensibility) value.
/// GREASE values follow pattern: 0x{0a,1a,2a,...,fa}0a
fn is_grease(val: u16) -> bool {
let hi = (val >> 8) as u8;
let lo = val as u8;
lo == 0x0a && hi & 0x0f == 0x0a
}
/// Compute a simple hash of sorted u16 values, return first 12 hex chars.
/// Uses FNV-1a for speed (no cryptographic requirement).
fn truncated_hash(values: &[u16]) -> String {
let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis
for &v in values {
let bytes = v.to_be_bytes();
for &b in &bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3); // FNV prime
}
}
format!("{:012x}", hash)[..12].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tls_event(
version: u16,
ciphers: &[u16],
extensions: &[u16],
has_sni: bool,
sni: &[u8],
) -> TlsComponentsEvent {
let mut event = TlsComponentsEvent {
src_ip: 0x0100007f,
dst_ip: 0xC0A80001u32.to_be(),
src_port: 54321,
dst_port: 443,
tls_version: version,
cipher_count: ciphers.len().min(20) as u8,
ext_count: extensions.len().min(20) as u8,
ciphers: [0u16; 20],
extensions: [0u16; 20],
sni: [0u8; 32],
alpn_first_len: 0,
has_sni: if has_sni { 1 } else { 0 },
timestamp_ns: 0,
_padding: [0; 2],
};
for (i, &c) in ciphers.iter().take(20).enumerate() {
event.ciphers[i] = c;
}
for (i, &e) in extensions.iter().take(20).enumerate() {
event.extensions[i] = e;
}
let copy_len = sni.len().min(32);
event.sni[..copy_len].copy_from_slice(&sni[..copy_len]);
event
}
#[test]
fn ja4_tls13_with_sni() {
let event = make_tls_event(
0x0304,
&[0x1301, 0x1302, 0x1303],
&[0x0000, 0x000a, 0x000b, 0x000d],
true,
b"example.com",
);
let fp = Ja4Assembler::assemble(&event);
assert!(fp.fingerprint.starts_with("t13d0304_"));
assert_eq!(fp.sni, Some("example.com".to_string()));
assert_eq!(fp.dst_port, 443);
}
#[test]
fn ja4_tls12_no_sni() {
let event = make_tls_event(
0x0303,
&[0xc02c, 0xc02b, 0x009e],
&[0x000a, 0x000b],
false,
&[],
);
let fp = Ja4Assembler::assemble(&event);
assert!(fp.fingerprint.starts_with("t12i0302_"));
assert_eq!(fp.sni, None);
}
#[test]
fn grease_values_filtered() {
// 0x0a0a is a GREASE value
assert!(is_grease(0x0a0a));
assert!(is_grease(0x1a0a));
assert!(is_grease(0xfa0a));
assert!(!is_grease(0x0001));
assert!(!is_grease(0x1301));
}
#[test]
fn truncated_hash_deterministic() {
let h1 = truncated_hash(&[0x1301, 0x1302, 0x1303]);
let h2 = truncated_hash(&[0x1301, 0x1302, 0x1303]);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 12);
}
#[test]
fn truncated_hash_order_matters() {
// Input is pre-sorted, so different order = different hash
let h1 = truncated_hash(&[0x0001, 0x0002]);
let h2 = truncated_hash(&[0x0002, 0x0001]);
assert_ne!(h1, h2);
}
#[test]
fn tls_version_mapping() {
assert_eq!(tls_version_code(0x0304), "13");
assert_eq!(tls_version_code(0x0303), "12");
assert_eq!(tls_version_code(0x0302), "11");
assert_eq!(tls_version_code(0x0301), "10");
assert_eq!(tls_version_code(0x0300), "s3");
assert_eq!(tls_version_code(0x0200), "00");
}
}

167
blackwall/src/ja4/db.rs Executable file
View file

@ -0,0 +1,167 @@
//! JA4 fingerprint database: known fingerprint matching.
//!
//! Maintains a HashMap of known JA4 fingerprints to tool/client names.
//! Can be populated from a static list or loaded from a config file.
use std::collections::HashMap;
/// Result of matching a JA4 fingerprint against the database.
#[derive(Debug, Clone, PartialEq)]
pub enum Ja4Match {
/// Known malicious tool.
Malicious { name: String, confidence: f32 },
/// Known legitimate client.
Benign { name: String },
/// No match in database.
Unknown,
}
/// Database of known JA4 fingerprints.
pub struct Ja4Database {
/// Maps JA4 fingerprint prefix (first segment before underscore) → entries.
entries: HashMap<String, Ja4Entry>,
}
#[derive(Debug, Clone)]
struct Ja4Entry {
name: String,
is_malicious: bool,
confidence: f32,
}
impl Ja4Database {
/// Create an empty database.
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
/// Create a database pre-populated with common known fingerprints.
pub fn with_defaults() -> Self {
let mut db = Self::new();
// Known scanning tools
db.add_malicious("t13d0103", "nmap", 0.9);
db.add_malicious("t12i0003", "masscan", 0.85);
db.add_malicious("t12i0103", "zgrab2", 0.8);
db.add_malicious("t13d0203", "nuclei", 0.85);
db.add_malicious("t12d0305", "sqlmap", 0.9);
db.add_malicious("t13d0105", "gobuster", 0.8);
// Known legitimate clients
db.add_benign("t13d1510", "Chrome/modern");
db.add_benign("t13d1609", "Firefox/modern");
db.add_benign("t13d0907", "Safari/modern");
db.add_benign("t13d1208", "Edge/modern");
db.add_benign("t13d0605", "curl");
db.add_benign("t13d0404", "python-requests");
db
}
/// Add a known malicious fingerprint.
pub fn add_malicious(&mut self, prefix: &str, name: &str, confidence: f32) {
self.entries.insert(
prefix.to_string(),
Ja4Entry {
name: name.to_string(),
is_malicious: true,
confidence,
},
);
}
/// Add a known benign fingerprint.
pub fn add_benign(&mut self, prefix: &str, name: &str) {
self.entries.insert(
prefix.to_string(),
Ja4Entry {
name: name.to_string(),
is_malicious: false,
confidence: 0.0,
},
);
}
/// Look up a JA4 fingerprint. Matches on the first segment (before first '_').
pub fn lookup(&self, fingerprint: &str) -> Ja4Match {
let prefix = fingerprint
.split('_')
.next()
.unwrap_or(fingerprint);
match self.entries.get(prefix) {
Some(entry) if entry.is_malicious => Ja4Match::Malicious {
name: entry.name.clone(),
confidence: entry.confidence,
},
Some(entry) => Ja4Match::Benign {
name: entry.name.clone(),
},
None => Ja4Match::Unknown,
}
}
/// Number of entries in the database.
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the database is empty.
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_database_returns_unknown() {
let db = Ja4Database::new();
assert_eq!(db.lookup("t13d1510_abc123_def456"), Ja4Match::Unknown);
}
#[test]
fn defaults_include_known_tools() {
let db = Ja4Database::with_defaults();
assert!(db.len() > 0);
match db.lookup("t13d0103_anything_here") {
Ja4Match::Malicious { name, .. } => assert_eq!(name, "nmap"),
other => panic!("expected nmap match, got {:?}", other),
}
}
#[test]
fn benign_lookup() {
let db = Ja4Database::with_defaults();
match db.lookup("t13d1510_hash1_hash2") {
Ja4Match::Benign { name } => assert_eq!(name, "Chrome/modern"),
other => panic!("expected Chrome match, got {:?}", other),
}
}
#[test]
fn unknown_fingerprint() {
let db = Ja4Database::with_defaults();
assert_eq!(db.lookup("t13d9999_unknown_hash"), Ja4Match::Unknown);
}
#[test]
fn custom_entries() {
let mut db = Ja4Database::new();
db.add_malicious("t12i0201", "custom_scanner", 0.75);
match db.lookup("t12i0201_hash_hash") {
Ja4Match::Malicious { name, confidence } => {
assert_eq!(name, "custom_scanner");
assert!((confidence - 0.75).abs() < 0.01);
}
other => panic!("expected custom scanner, got {:?}", other),
}
}
}

12
blackwall/src/ja4/mod.rs Executable file
View file

@ -0,0 +1,12 @@
//! JA4 TLS fingerprinting module.
//!
//! Assembles JA4 fingerprints from raw TLS ClientHello components
//! emitted by the eBPF program. JA4 format:
//! `t{TLSver}{SNI}{CipherCount}{ExtCount}_{CipherHash}_{ExtHash}`
//!
//! Reference: https://github.com/FoxIO-LLC/ja4
// ARCH: JA4 module is wired into the main event loop via TLS_EVENTS RingBuf.
// eBPF TLS ClientHello parser emits TlsComponentsEvent → JA4 assembly → DB lookup.
pub mod assembler;
pub mod db;

1223
blackwall/src/main.rs Executable file

File diff suppressed because it is too large Load diff

97
blackwall/src/metrics.rs Executable file
View file

@ -0,0 +1,97 @@
use anyhow::Result;
use aya::maps::{MapData, PerCpuArray};
use common::Counters;
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::Request;
use hyper_util::rt::TokioIo;
use std::time::Duration;
const HIVEMIND_PUSH_URL: &str = "http://127.0.0.1:8090/push";
/// Periodically read eBPF COUNTERS (sum across CPUs), log via tracing,
/// and push the latest values to hivemind-api for the live dashboard.
pub async fn metrics_tick(
counters: PerCpuArray<MapData, Counters>,
interval_secs: u64,
) -> Result<()> {
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
loop {
interval.tick().await;
let values = match counters.get(&0, 0) {
Ok(v) => v,
Err(e) => {
tracing::warn!("failed to read counters: {}", e);
continue;
}
};
let total = values.iter().fold(
Counters {
packets_total: 0,
packets_passed: 0,
packets_dropped: 0,
anomalies_sent: 0,
},
|acc, c| Counters {
packets_total: acc.packets_total + c.packets_total,
packets_passed: acc.packets_passed + c.packets_passed,
packets_dropped: acc.packets_dropped + c.packets_dropped,
anomalies_sent: acc.anomalies_sent + c.anomalies_sent,
},
);
tracing::info!(
total = total.packets_total,
passed = total.packets_passed,
dropped = total.packets_dropped,
anomalies = total.anomalies_sent,
"counters"
);
// Push live metrics to hivemind-api dashboard
push_to_hivemind(
total.packets_total,
total.packets_passed,
total.packets_dropped,
total.anomalies_sent,
)
.await;
}
}
/// Fire-and-forget push of current eBPF counter totals to hivemind-api.
async fn push_to_hivemind(
packets_total: u64,
packets_passed: u64,
packets_dropped: u64,
anomalies_sent: u64,
) {
let body = format!(
r#"{{"packets_total":{packets_total},"packets_passed":{packets_passed},"packets_dropped":{packets_dropped},"anomalies_sent":{anomalies_sent}}}"#
);
let result = async {
let stream = tokio::net::TcpStream::connect("127.0.0.1:8090").await?;
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
tokio::spawn(async move {
if let Err(e) = conn.await {
tracing::debug!(error = %e, "hivemind-api push connection dropped");
}
});
let req = Request::builder()
.method("POST")
.uri(HIVEMIND_PUSH_URL)
.header("content-type", "application/json")
.header("host", "127.0.0.1")
.body(Full::new(Bytes::from(body)))?;
sender.send_request(req).await?;
anyhow::Ok(())
}
.await;
if let Err(e) = result {
tracing::debug!(error = %e, "failed to push metrics to hivemind-api (daemon may not be running)");
}
}

297
blackwall/src/pcap.rs Executable file
View file

@ -0,0 +1,297 @@
//! PCAP file writer for forensic packet capture.
//!
//! Writes pcap-format files for flagged IPs. Uses the standard pcap file
//! format (libpcap) without any external dependencies.
#![allow(dead_code)]
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::io::Write;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::Mutex;
/// PCAP global header magic number (microsecond resolution).
const PCAP_MAGIC: u32 = 0xa1b2c3d4;
/// PCAP version 2.4.
const PCAP_VERSION_MAJOR: u16 = 2;
const PCAP_VERSION_MINOR: u16 = 4;
/// Maximum bytes to capture per packet.
const SNAP_LEN: u32 = 65535;
/// Link type: raw IPv4 (DLT_RAW = 228). Alternative: Ethernet = 1.
const LINK_TYPE_RAW: u32 = 228;
/// Maximum PCAP file size before rotation (100 MB).
const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
/// Maximum number of rotated files to keep.
const MAX_ROTATED_FILES: usize = 10;
/// Manages PCAP capture for flagged IPs.
pub struct PcapWriter {
/// Directory to write pcap files.
output_dir: PathBuf,
/// Set of IPs currently flagged for capture.
flagged_ips: Mutex<HashSet<u32>>,
/// Currently open pcap file (if any).
file: Mutex<Option<PcapFile>>,
}
struct PcapFile {
writer: std::io::BufWriter<std::fs::File>,
path: PathBuf,
bytes_written: u64,
}
impl PcapWriter {
/// Create a new PCAP writer with the given output directory.
pub fn new(output_dir: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&output_dir)
.with_context(|| format!("failed to create pcap dir: {}", output_dir.display()))?;
Ok(Self {
output_dir,
flagged_ips: Mutex::new(HashSet::new()),
file: Mutex::new(None),
})
}
/// Flag an IP for packet capture.
pub fn flag_ip(&self, ip: Ipv4Addr) {
let raw = u32::from(ip);
let mut ips = self.flagged_ips.lock().expect("flagged_ips lock");
if ips.insert(raw) {
tracing::info!(%ip, "PCAP capture enabled for IP");
}
}
/// Remove an IP from capture.
#[allow(dead_code)]
pub fn unflag_ip(&self, ip: Ipv4Addr) {
let raw = u32::from(ip);
let mut ips = self.flagged_ips.lock().expect("flagged_ips lock");
if ips.remove(&raw) {
tracing::info!(%ip, "PCAP capture disabled for IP");
}
}
/// Check if an IP is flagged for capture.
pub fn is_flagged(&self, src_ip: u32) -> bool {
let ips = self.flagged_ips.lock().expect("flagged_ips lock");
ips.contains(&src_ip)
}
/// Write a raw IP packet to the pcap file (if the IP is flagged).
pub fn write_packet(&self, src_ip: u32, dst_ip: u32, data: &[u8]) -> Result<()> {
// Check if either endpoint is flagged
let ips = self.flagged_ips.lock().expect("flagged_ips lock");
if !ips.contains(&src_ip) && !ips.contains(&dst_ip) {
return Ok(());
}
drop(ips); // Release lock before I/O
let mut file_guard = self.file.lock().expect("pcap file lock");
// Open file if needed, or rotate if too large
let pcap = match file_guard.as_mut() {
Some(f) if f.bytes_written < MAX_FILE_SIZE => f,
Some(_) => {
// Rotate
let old = file_guard.take().expect("just checked Some");
drop(old);
self.rotate_files()?;
let new_file = self.open_new_file()?;
*file_guard = Some(new_file);
file_guard.as_mut().expect("just created")
}
None => {
let new_file = self.open_new_file()?;
*file_guard = Some(new_file);
file_guard.as_mut().expect("just created")
}
};
// Write pcap packet record
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let ts_sec = ts.as_secs() as u32;
let ts_usec = ts.subsec_micros();
let cap_len = data.len().min(SNAP_LEN as usize) as u32;
// Packet header: ts_sec(4) + ts_usec(4) + cap_len(4) + orig_len(4)
pcap.writer.write_all(&ts_sec.to_le_bytes())?;
pcap.writer.write_all(&ts_usec.to_le_bytes())?;
pcap.writer.write_all(&cap_len.to_le_bytes())?;
pcap.writer.write_all(&(data.len() as u32).to_le_bytes())?;
pcap.writer.write_all(&data[..cap_len as usize])?;
pcap.writer.flush()?;
pcap.bytes_written += 16 + cap_len as u64;
Ok(())
}
/// Open a new pcap file with a global header.
fn open_new_file(&self) -> Result<PcapFile> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let path = self.output_dir.join(format!("capture_{}.pcap", timestamp));
let file = std::fs::File::create(&path)
.with_context(|| format!("failed to create pcap: {}", path.display()))?;
let mut writer = std::io::BufWriter::new(file);
// Write pcap global header
writer.write_all(&PCAP_MAGIC.to_le_bytes())?;
writer.write_all(&PCAP_VERSION_MAJOR.to_le_bytes())?;
writer.write_all(&PCAP_VERSION_MINOR.to_le_bytes())?;
writer.write_all(&0i32.to_le_bytes())?; // thiszone
writer.write_all(&0u32.to_le_bytes())?; // sigfigs
writer.write_all(&SNAP_LEN.to_le_bytes())?;
writer.write_all(&LINK_TYPE_RAW.to_le_bytes())?;
writer.flush()?;
tracing::info!(path = %path.display(), "opened new PCAP file");
Ok(PcapFile {
writer,
path,
bytes_written: 24, // Global header size
})
}
/// Rotate pcap files: remove oldest if exceeding MAX_ROTATED_FILES.
fn rotate_files(&self) -> Result<()> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(&self.output_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "pcap" || ext == "gz")
.unwrap_or(false)
})
.map(|e| e.path())
.collect();
entries.sort();
while entries.len() >= MAX_ROTATED_FILES {
if let Some(oldest) = entries.first() {
tracing::info!(path = %oldest.display(), "rotating old PCAP file");
std::fs::remove_file(oldest)?;
entries.remove(0);
}
}
Ok(())
}
/// Compress a pcap file using gzip. Returns path to compressed file.
pub fn compress_file(path: &std::path::Path) -> Result<PathBuf> {
use std::process::Command;
let output = Command::new("gzip")
.arg("-f") // Force overwrite
.arg(path.as_os_str())
.output()
.with_context(|| format!("failed to run gzip on {}", path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("gzip failed: {}", stderr);
}
let gz_path = path.with_extension("pcap.gz");
Ok(gz_path)
}
/// Get the number of flagged IPs.
#[allow(dead_code)]
pub fn flagged_count(&self) -> usize {
self.flagged_ips.lock().expect("flagged_ips lock").len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pcap_global_header_size() {
// PCAP global header is exactly 24 bytes
let size = 4 + 2 + 2 + 4 + 4 + 4 + 4; // magic + ver_maj + ver_min + tz + sigfigs + snaplen + link
assert_eq!(size, 24);
}
#[test]
fn flag_and_check_ip() {
let dir = std::env::temp_dir().join("blackwall_pcap_test");
let writer = PcapWriter::new(dir.clone()).unwrap();
let ip = Ipv4Addr::new(192, 168, 1, 1);
assert!(!writer.is_flagged(u32::from(ip)));
writer.flag_ip(ip);
assert!(writer.is_flagged(u32::from(ip)));
writer.unflag_ip(ip);
assert!(!writer.is_flagged(u32::from(ip)));
// Cleanup
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn write_and_verify_pcap() {
let dir = std::env::temp_dir().join("blackwall_pcap_write_test");
let writer = PcapWriter::new(dir.clone()).unwrap();
let src_ip = Ipv4Addr::new(10, 0, 0, 1);
writer.flag_ip(src_ip);
// Fake IP packet (just some bytes)
let packet = [0x45, 0x00, 0x00, 0x28, 0x00, 0x01, 0x00, 0x00,
0x40, 0x06, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x01,
0xc0, 0xa8, 0x01, 0x01];
writer.write_packet(u32::from(src_ip), u32::from(Ipv4Addr::new(192, 168, 1, 1)), &packet).unwrap();
// Verify file was created
let files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "pcap").unwrap_or(false))
.collect();
assert_eq!(files.len(), 1);
// Verify file starts with pcap magic
let content = std::fs::read(files[0].path()).unwrap();
assert!(content.len() >= 24); // At least global header
let magic = u32::from_le_bytes([content[0], content[1], content[2], content[3]]);
assert_eq!(magic, PCAP_MAGIC);
// Cleanup
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn unflagged_ip_not_captured() {
let dir = std::env::temp_dir().join("blackwall_pcap_skip_test");
let writer = PcapWriter::new(dir.clone()).unwrap();
let packet = [0x45, 0x00];
// No IPs flagged — should be a no-op
writer.write_packet(0x0a000001, 0xc0a80101, &packet).unwrap();
let files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "pcap").unwrap_or(false))
.collect();
assert!(files.is_empty());
// Cleanup
let _ = std::fs::remove_dir_all(dir);
}
}

156
blackwall/src/rules.rs Executable file
View file

@ -0,0 +1,156 @@
use anyhow::{Context, Result};
use aya::maps::{HashMap, LpmTrie, MapData};
use common::{RuleAction, RuleKey, RuleValue};
/// Manages eBPF maps for IP blocklist, CIDR rules, and expiry.
pub struct RuleManager {
blocklist: HashMap<MapData, RuleKey, RuleValue>,
#[allow(dead_code)]
cidr_rules: LpmTrie<MapData, u32, RuleValue>,
}
impl RuleManager {
/// Create a new RuleManager from opened eBPF maps.
pub fn new(
blocklist: HashMap<MapData, RuleKey, RuleValue>,
cidr_rules: LpmTrie<MapData, u32, RuleValue>,
) -> Self {
Self {
blocklist,
cidr_rules,
}
}
/// Block an IP for `duration_secs` seconds (0 = permanent).
pub fn block_ip(&mut self, ip: u32, duration_secs: u32) -> Result<()> {
let key = RuleKey { ip };
let value = RuleValue {
action: RuleAction::Drop as u8,
_pad1: 0,
_pad2: 0,
expires_at: if duration_secs == 0 {
0
} else {
current_boot_secs() + duration_secs
},
};
self.blocklist
.insert(key, value, 0)
.context("failed to insert blocklist entry")?;
Ok(())
}
/// Explicitly allow an IP (permanent).
pub fn allow_ip(&mut self, ip: u32) -> Result<()> {
let key = RuleKey { ip };
let value = RuleValue {
action: RuleAction::Pass as u8,
_pad1: 0,
_pad2: 0,
expires_at: 0,
};
self.blocklist
.insert(key, value, 0)
.context("failed to insert allow entry")?;
Ok(())
}
/// Redirect an IP to the tarpit for `duration_secs`.
pub fn redirect_to_tarpit(&mut self, ip: u32, duration_secs: u32) -> Result<()> {
let key = RuleKey { ip };
let value = RuleValue {
action: RuleAction::RedirectTarpit as u8,
_pad1: 0,
_pad2: 0,
expires_at: if duration_secs == 0 {
0
} else {
current_boot_secs() + duration_secs
},
};
self.blocklist
.insert(key, value, 0)
.context("failed to insert tarpit redirect")?;
Ok(())
}
/// Check if an IP has a non-pass action (Drop or RedirectTarpit) in the blocklist.
pub fn is_blocked_or_redirected(&self, ip: u32) -> bool {
let key = RuleKey { ip };
match self.blocklist.get(&key, 0) {
Ok(v) => v.action != RuleAction::Pass as u8,
Err(_) => false,
}
}
/// Remove an IP from the blocklist.
#[allow(dead_code)]
pub fn remove_ip(&mut self, ip: u32) -> Result<()> {
let key = RuleKey { ip };
self.blocklist
.remove(&key)
.context("failed to remove blocklist entry")?;
Ok(())
}
/// Add a CIDR rule (e.g., block 10.0.0.0/8).
pub fn add_cidr_rule(&mut self, ip: u32, prefix: u32, action: RuleAction) -> Result<()> {
let lpm_key = aya::maps::lpm_trie::Key::new(prefix, ip);
let value = RuleValue {
action: action as u8,
_pad1: 0,
_pad2: 0,
expires_at: 0,
};
self.cidr_rules
.insert(&lpm_key, value, 0)
.context("failed to insert CIDR rule")?;
Ok(())
}
/// Remove expired rules from the blocklist. Returns expired IPs.
pub fn expire_stale_rules(&mut self) -> Result<Vec<u32>> {
let now = current_boot_secs();
let mut expired_keys = Vec::new();
// Collect keys to expire
for result in self.blocklist.iter() {
let (key, value) = result.context("error iterating blocklist")?;
if value.expires_at != 0 && value.expires_at < now {
expired_keys.push(key);
}
}
let expired_ips: Vec<u32> = expired_keys.iter().map(|k| k.ip).collect();
for key in expired_keys {
let _ = self.blocklist.remove(&key);
}
Ok(expired_ips)
}
/// Remove all CIDR rules. Called before feed refresh to avoid stale entries.
pub fn clear_cidr_rules(&mut self) -> Result<()> {
let keys: Vec<_> = self
.cidr_rules
.iter()
.filter_map(|r| r.ok())
.map(|(k, _)| k)
.collect();
for key in keys {
let _ = self.cidr_rules.remove(&key);
}
Ok(())
}
}
/// Approximate seconds since boot using CLOCK_BOOTTIME.
fn current_boot_secs() -> u32 {
let mut ts = nix::libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: valid pointer, CLOCK_BOOTTIME is a valid clock_id
unsafe { nix::libc::clock_gettime(nix::libc::CLOCK_BOOTTIME, &mut ts) };
ts.tv_sec as u32
}

View file

@ -0,0 +1,389 @@
//! Integration tests for the distributed peer protocol.
//!
//! Tests persistent TCP connections, broadcast reuse, and heartbeat cycle.
//! Run: `cargo test -p blackwall --test peer_integration -- --nocapture`
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use ring::hmac;
// Re-import what we need from the crate
// NOTE: These tests exercise the wire protocol directly since peer internals
// are private. They verify the protocol codec + flow end-to-end.
/// Wire protocol magic.
const PROTOCOL_MAGIC: [u8; 4] = [0x42, 0x57, 0x4C, 0x01];
const HELLO_TYPE: u8 = 0x01;
const BLOCKED_IP_TYPE: u8 = 0x02;
const HEARTBEAT_TYPE: u8 = 0x04;
const HMAC_SIZE: usize = 32;
const HEADER_SIZE: usize = 4 + 1 + 4 + HMAC_SIZE; // 41
/// Shared test PSK.
fn test_key() -> hmac::Key {
hmac::Key::new(hmac::HMAC_SHA256, b"integration-test-psk-blackwall")
}
/// Encode a message using the V2 wire protocol:
/// magic(4) + type(1) + len(4) + hmac(32) + payload.
fn encode_message(msg_type: u8, payload: &[u8], key: &hmac::Key) -> Vec<u8> {
let len = payload.len() as u32;
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&PROTOCOL_MAGIC);
buf.push(msg_type);
buf.extend_from_slice(&len.to_le_bytes());
// Compute HMAC over magic + type + len + payload
let mut signing_ctx = hmac::Context::with_key(key);
signing_ctx.update(&PROTOCOL_MAGIC);
signing_ctx.update(&[msg_type]);
signing_ctx.update(&len.to_le_bytes());
signing_ctx.update(payload);
let tag = signing_ctx.sign();
buf.extend_from_slice(tag.as_ref());
buf.extend_from_slice(payload);
buf
}
/// Read a single framed message from a stream.
/// Returns (type_byte, payload). Panics on HMAC mismatch.
async fn read_frame(stream: &mut tokio::net::TcpStream, key: &hmac::Key) -> (u8, Vec<u8>) {
let mut header = [0u8; HEADER_SIZE];
stream.read_exact(&mut header).await.expect("read header");
assert_eq!(&header[..4], &PROTOCOL_MAGIC, "bad magic");
let msg_type = header[4];
let payload_len =
u32::from_le_bytes([header[5], header[6], header[7], header[8]]) as usize;
let hmac_tag = &header[9..HEADER_SIZE];
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
stream.read_exact(&mut payload).await.expect("read payload");
}
// Verify HMAC
let mut verify_data = Vec::with_capacity(9 + payload.len());
verify_data.extend_from_slice(&header[..9]);
verify_data.extend_from_slice(&payload);
hmac::verify(key, &verify_data, hmac_tag)
.expect("HMAC verification failed — wrong key or tampered data");
(msg_type, payload)
}
#[tokio::test(flavor = "current_thread")]
async fn peer_hello_handshake() {
// Simulate a sensor listening and a controller connecting
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let key = test_key();
let server_key = key.clone();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
// Read HELLO from client
let (msg_type, payload) = read_frame(&mut stream, &server_key).await;
assert_eq!(msg_type, HELLO_TYPE);
let hello: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(hello["node_id"], "test-client");
// Send HELLO response
let resp = serde_json::json!({
"node_id": "test-server",
"version": "1.0.0",
"blocked_count": 42
});
let resp_bytes = serde_json::to_vec(&resp).unwrap();
let msg = encode_message(HELLO_TYPE, &resp_bytes, &server_key);
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
});
let client_key = key.clone();
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
// Send HELLO
let hello = serde_json::json!({
"node_id": "test-client",
"version": "1.0.0",
"blocked_count": 0
});
let hello_bytes = serde_json::to_vec(&hello).unwrap();
let msg = encode_message(HELLO_TYPE, &hello_bytes, &client_key);
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
// Read HELLO response
let (msg_type, payload) = read_frame(&mut stream, &client_key).await;
assert_eq!(msg_type, HELLO_TYPE);
let resp: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(resp["node_id"], "test-server");
assert_eq!(resp["blocked_count"], 42);
});
tokio::try_join!(server_task, client_task).unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn peer_heartbeat_response_cycle() {
// Verify that heartbeat → HELLO response cycle works over persistent TCP
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let key = test_key();
let server_key = key.clone();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
// Read 3 heartbeats, respond to each with HELLO
for i in 0..3u32 {
let (msg_type, payload) = read_frame(&mut stream, &server_key).await;
assert_eq!(msg_type, HEARTBEAT_TYPE, "expected heartbeat {}", i);
assert_eq!(payload.len(), 0, "heartbeat payload should be empty");
// Respond with HELLO containing metrics
let resp = serde_json::json!({
"node_id": "sensor-1",
"version": "1.0.0",
"blocked_count": 10 + i
});
let resp_bytes = serde_json::to_vec(&resp).unwrap();
let msg = encode_message(HELLO_TYPE, &resp_bytes, &server_key);
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
}
});
let client_key = key.clone();
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
// Send 3 heartbeats on the SAME connection and verify responses
for i in 0..3u32 {
let heartbeat = encode_message(HEARTBEAT_TYPE, &[], &client_key);
stream.write_all(&heartbeat).await.unwrap();
stream.flush().await.unwrap();
let (msg_type, payload) = read_frame(&mut stream, &client_key).await;
assert_eq!(msg_type, HELLO_TYPE, "expected HELLO response {}", i);
let resp: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(resp["blocked_count"], 10 + i);
}
// Connection still alive after 3 exchanges — persistent TCP works
});
tokio::try_join!(server_task, client_task).unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn multiple_blocked_ip_on_single_connection() {
// Verify that multiple BlockedIp messages can be sent on a single TCP stream
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let key = test_key();
let server_key = key.clone();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
// Read 5 blocked IPs on the same connection
for i in 0..5u32 {
let (msg_type, payload) = read_frame(&mut stream, &server_key).await;
assert_eq!(msg_type, BLOCKED_IP_TYPE);
let blocked: serde_json::Value = serde_json::from_slice(&payload).unwrap();
let expected_ip = format!("10.0.0.{}", i + 1);
assert_eq!(blocked["ip"], expected_ip);
assert!(blocked["confidence"].as_u64().unwrap() >= 50);
}
});
let client_key = key.clone();
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
// Send 5 blocked IPs on the same connection
for i in 0..5u32 {
let payload = serde_json::json!({
"ip": format!("10.0.0.{}", i + 1),
"reason": "integration test",
"duration_secs": 600,
"confidence": 85
});
let payload_bytes = serde_json::to_vec(&payload).unwrap();
let msg = encode_message(BLOCKED_IP_TYPE, &payload_bytes, &client_key);
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
}
});
tokio::try_join!(server_task, client_task).unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn invalid_magic_rejected() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
// Connection should be dropped by peer after sending garbage
let result = tokio::time::timeout(
Duration::from_secs(2),
stream.read(&mut buf),
).await;
// Either timeout or read some bytes — just verify no panic
drop(result);
});
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
// Send garbage with wrong magic (pad to HEADER_SIZE)
let mut garbage = vec![0xFF; HEADER_SIZE];
garbage[4] = HELLO_TYPE;
stream.write_all(&garbage).await.unwrap();
stream.flush().await.unwrap();
});
tokio::try_join!(server_task, client_task).unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn oversized_payload_rejected() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
// Read header claiming 1MB payload — should be rejected as too large
let mut header = [0u8; HEADER_SIZE];
stream.read_exact(&mut header).await.unwrap();
assert_eq!(&header[..4], &PROTOCOL_MAGIC);
let payload_len =
u32::from_le_bytes([header[5], header[6], header[7], header[8]]) as usize;
assert!(payload_len > 65536, "payload should be oversized");
// Server would reject this — test verifies the frame was parseable
});
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
// Send header with 1MB payload length (but don't send payload)
let mut msg = vec![0u8; HEADER_SIZE];
msg[..4].copy_from_slice(&PROTOCOL_MAGIC);
msg[4] = HELLO_TYPE;
let huge_len: u32 = 1_000_000;
msg[5..9].copy_from_slice(&huge_len.to_le_bytes());
// HMAC is garbage — but we're testing payload size rejection, not auth
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
});
tokio::try_join!(server_task, client_task).unwrap();
}
/// Test that a tampered HMAC causes frame rejection.
#[tokio::test(flavor = "current_thread")]
async fn hmac_tamper_detection() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let key = test_key();
let server_key = key.clone();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut header = [0u8; HEADER_SIZE];
let result = tokio::time::timeout(
Duration::from_secs(2),
stream.read_exact(&mut header),
).await;
// The frame arrives, but HMAC verification should fail
if let Ok(Ok(_)) = result {
let payload_len =
u32::from_le_bytes([header[5], header[6], header[7], header[8]]) as usize;
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
let _ = stream.read_exact(&mut payload).await;
}
// Verify HMAC — should fail because we tampered
let mut verify_data = Vec::with_capacity(9 + payload.len());
verify_data.extend_from_slice(&header[..9]);
verify_data.extend_from_slice(&payload);
let result = hmac::verify(
&server_key, &verify_data, &header[9..HEADER_SIZE],
);
assert!(result.is_err(), "tampered HMAC should fail verification");
}
});
let client_key = key.clone();
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let payload = serde_json::json!({"node_id": "evil"});
let payload_bytes = serde_json::to_vec(&payload).unwrap();
let mut msg = encode_message(HELLO_TYPE, &payload_bytes, &client_key);
// Tamper: flip a byte in the HMAC region (positions 9..41)
msg[15] ^= 0xFF;
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
});
tokio::try_join!(server_task, client_task).unwrap();
}
/// Test that a wrong PSK causes HMAC rejection.
#[tokio::test(flavor = "current_thread")]
async fn wrong_psk_rejected() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_key = test_key();
let wrong_key = hmac::Key::new(hmac::HMAC_SHA256, b"wrong-psk-not-matching");
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut header = [0u8; HEADER_SIZE];
let result = tokio::time::timeout(
Duration::from_secs(2),
stream.read_exact(&mut header),
).await;
if let Ok(Ok(_)) = result {
let payload_len =
u32::from_le_bytes([header[5], header[6], header[7], header[8]]) as usize;
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
let _ = stream.read_exact(&mut payload).await;
}
let mut verify_data = Vec::with_capacity(9 + payload.len());
verify_data.extend_from_slice(&header[..9]);
verify_data.extend_from_slice(&payload);
let result = hmac::verify(
&server_key, &verify_data, &header[9..HEADER_SIZE],
);
assert!(result.is_err(), "wrong PSK should fail verification");
}
});
let client_task = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let payload = serde_json::json!({"node_id": "intruder"});
let payload_bytes = serde_json::to_vec(&payload).unwrap();
// Signed with wrong key
let msg = encode_message(HELLO_TYPE, &payload_bytes, &wrong_key);
stream.write_all(&msg).await.unwrap();
stream.flush().await.unwrap();
});
tokio::try_join!(server_task, client_task).unwrap();
}

17
common/Cargo.toml Executable file
View file

@ -0,0 +1,17 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[features]
default = ["user", "aya"]
user = ["dep:serde", "dep:serde_json"]
aya = ["dep:aya", "user"]
[dependencies]
aya = { version = "0.13", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
[lib]
path = "src/lib.rs"

157
common/src/base64.rs Executable file
View file

@ -0,0 +1,157 @@
//! Minimal base64/base64url codec — no external crates.
//!
//! Used by the A2A firewall (JWT parsing, PoP verification) and
//! the A2A shim (token encoding). A single implementation avoids
//! drift between the three nearly-identical copies that existed before.
/// Standard base64 alphabet lookup table (6-bit value per ASCII char).
const DECODE_TABLE: &[u8; 128] = &{
let mut t = [255u8; 128];
let mut i = 0u8;
while i < 26 {
t[(b'A' + i) as usize] = i;
t[(b'a' + i) as usize] = i + 26;
i += 1;
}
let mut i = 0u8;
while i < 10 {
t[(b'0' + i) as usize] = i + 52;
i += 1;
}
t[b'+' as usize] = 62;
t[b'/' as usize] = 63;
t
};
/// Base64url alphabet for encoding (RFC 4648 §5, no padding).
const ENCODE_TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
/// Decode a base64url string (no padding required) into bytes.
///
/// Handles the URL-safe alphabet (`-` → `+`, `_` → `/`) and adds
/// padding automatically before delegating to the standard decoder.
pub fn decode_base64url(input: &str) -> Result<Vec<u8>, &'static str> {
// Add padding if needed
let padded = match input.len() % 4 {
2 => format!("{input}=="),
3 => format!("{input}="),
0 => input.to_string(),
_ => return Err("invalid base64url length"),
};
// Convert base64url → standard base64
let standard: String = padded
.chars()
.map(|c| match c {
'-' => '+',
'_' => '/',
other => other,
})
.collect();
decode_base64_standard(&standard)
}
/// Decode a standard base64 string (with `=` padding) into bytes.
pub fn decode_base64_standard(input: &str) -> Result<Vec<u8>, &'static str> {
let bytes = input.as_bytes();
let len = bytes.len();
if !len.is_multiple_of(4) {
return Err("invalid base64 length");
}
let mut out = Vec::with_capacity(len / 4 * 3);
let mut i = 0;
while i < len {
let a = bytes[i];
let b = bytes[i + 1];
let c = bytes[i + 2];
let d = bytes[i + 3];
let va = if a == b'=' { 0 } else if a > 127 { return Err("invalid char") } else { DECODE_TABLE[a as usize] };
let vb = if b == b'=' { 0 } else if b > 127 { return Err("invalid char") } else { DECODE_TABLE[b as usize] };
let vc = if c == b'=' { 0 } else if c > 127 { return Err("invalid char") } else { DECODE_TABLE[c as usize] };
let vd = if d == b'=' { 0 } else if d > 127 { return Err("invalid char") } else { DECODE_TABLE[d as usize] };
if va == 255 || vb == 255 || vc == 255 || vd == 255 {
return Err("invalid base64 character");
}
let triple = (va as u32) << 18 | (vb as u32) << 12 | (vc as u32) << 6 | (vd as u32);
out.push((triple >> 16) as u8);
if c != b'=' {
out.push((triple >> 8) as u8);
}
if d != b'=' {
out.push(triple as u8);
}
i += 4;
}
Ok(out)
}
/// Encode bytes to base64url (no padding, RFC 4648 §5).
pub fn encode_base64url(input: &[u8]) -> String {
let mut out = String::with_capacity((input.len() * 4 / 3) + 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let triple = (b0 << 16) | (b1 << 8) | b2;
out.push(ENCODE_TABLE[((triple >> 18) & 0x3F) as usize] as char);
out.push(ENCODE_TABLE[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(ENCODE_TABLE[((triple >> 6) & 0x3F) as usize] as char);
}
if chunk.len() > 2 {
out.push(ENCODE_TABLE[(triple & 0x3F) as usize] as char);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let data = b"Hello, Blackwall!";
let encoded = encode_base64url(data);
let decoded = decode_base64url(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn standard_base64_padding() {
// "Man" → "TWFu"
assert_eq!(decode_base64_standard("TWFu").unwrap(), b"Man");
// "Ma" → "TWE="
assert_eq!(decode_base64_standard("TWE=").unwrap(), b"Ma");
// "M" → "TQ=="
assert_eq!(decode_base64_standard("TQ==").unwrap(), b"M");
}
#[test]
fn url_safe_chars() {
// '+' and '/' in standard → '-' and '_' in url-safe
let standard = "ab+c/d==";
let url_safe = "ab-c_d";
let decode_std = decode_base64_standard(standard).unwrap();
let decode_url = decode_base64url(url_safe).unwrap();
assert_eq!(decode_std, decode_url);
}
#[test]
fn invalid_length() {
assert!(decode_base64url("A").is_err());
}
#[test]
fn invalid_char() {
assert!(decode_base64_standard("!!!!").is_err());
}
}

407
common/src/hivemind.rs Executable file
View file

@ -0,0 +1,407 @@
//! HiveMind Threat Mesh — shared types for P2P threat intelligence.
//!
//! These types are used by both the hivemind daemon and potentially
//! by eBPF programs that feed threat data into the mesh.
/// Maximum length of a JA4 fingerprint string (e.g., "t13d1516h2_8daaf6152771_e5627efa2ab1").
pub const JA4_FINGERPRINT_LEN: usize = 36;
/// Maximum length of a threat description.
pub const THREAT_DESC_LEN: usize = 128;
/// Maximum bootstrap nodes in configuration.
pub const MAX_BOOTSTRAP_NODES: usize = 16;
/// Default GossipSub fan-out.
pub const GOSSIPSUB_FANOUT: usize = 10;
/// Default GossipSub heartbeat interval in seconds.
pub const GOSSIPSUB_HEARTBEAT_SECS: u64 = 1;
/// Default Kademlia query timeout in seconds.
pub const KADEMLIA_QUERY_TIMEOUT_SECS: u64 = 60;
/// Maximum GossipSub message size (64 KB).
pub const MAX_MESSAGE_SIZE: usize = 65536;
/// GossipSub message deduplication TTL in seconds.
pub const MESSAGE_DEDUP_TTL_SECS: u64 = 120;
/// Kademlia k-bucket size.
pub const K_BUCKET_SIZE: usize = 20;
/// Severity level of a threat indicator.
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ThreatSeverity {
/// Informational — low confidence or low impact.
Info = 0,
/// Low — minor scanning or recon activity.
Low = 1,
/// Medium — active probing or known-bad pattern.
Medium = 2,
/// High — confirmed malicious with high confidence.
High = 3,
/// Critical — active exploitation or C2 communication.
Critical = 4,
}
impl ThreatSeverity {
/// Convert raw u8 to ThreatSeverity.
pub fn from_u8(v: u8) -> Self {
match v {
0 => ThreatSeverity::Info,
1 => ThreatSeverity::Low,
2 => ThreatSeverity::Medium,
3 => ThreatSeverity::High,
4 => ThreatSeverity::Critical,
_ => ThreatSeverity::Info,
}
}
}
/// Type of Indicator of Compromise.
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum IoCType {
/// IPv4 address associated with malicious activity.
MaliciousIp = 0,
/// JA4 TLS fingerprint of known malware/tool.
Ja4Fingerprint = 1,
/// High-entropy payload pattern (encrypted C2, exfil).
EntropyAnomaly = 2,
/// DNS tunneling indicator.
DnsTunnel = 3,
/// Behavioral pattern (port scan, brute force).
BehavioralPattern = 4,
}
impl IoCType {
/// Convert raw u8 to IoCType.
pub fn from_u8(v: u8) -> Self {
match v {
0 => IoCType::MaliciousIp,
1 => IoCType::Ja4Fingerprint,
2 => IoCType::EntropyAnomaly,
3 => IoCType::DnsTunnel,
4 => IoCType::BehavioralPattern,
_ => IoCType::MaliciousIp,
}
}
}
/// Indicator of Compromise — the core unit of threat intelligence shared
/// across the HiveMind mesh. Designed for GossipSub transmission.
///
/// This is a userspace-only type (not eBPF), so we use std types freely.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct IoC {
/// Type of indicator.
pub ioc_type: u8,
/// Severity level.
pub severity: u8,
/// IPv4 address (if applicable, 0 otherwise).
pub ip: u32,
/// JA4 fingerprint string (if applicable).
pub ja4: Option<String>,
/// Byte diversity score (unique_count × 31, if applicable).
pub entropy_score: Option<u32>,
/// Human-readable description.
pub description: String,
/// Unix timestamp when this IoC was first observed.
pub first_seen: u64,
/// Number of independent peers that confirmed this IoC.
pub confirmations: u32,
/// ZKP proof blob (empty until Phase 1 implementation).
pub zkp_proof: Vec<u8>,
}
/// A threat report broadcast via GossipSub. Contains one or more IoCs
/// from a single reporting node.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ThreatReport {
/// Unique report ID (UUID v4 bytes).
pub report_id: [u8; 16],
/// Reporter's Ed25519 public key (32 bytes).
pub reporter_pubkey: [u8; 32],
/// Unix timestamp of the report.
pub timestamp: u64,
/// List of IoCs in this report.
pub indicators: Vec<IoC>,
/// Ed25519 signature over the serialized indicators.
pub signature: Vec<u8>,
}
/// GossipSub topic identifiers for HiveMind.
pub mod topics {
/// IoC broadcast topic — new threat indicators.
pub const IOC_TOPIC: &str = "hivemind/ioc/v1";
/// JA4 fingerprint sharing topic.
pub const JA4_TOPIC: &str = "hivemind/ja4/v1";
/// Federated learning gradient exchange topic.
pub const GRADIENT_TOPIC: &str = "hivemind/federated/gradients/v1";
/// Peer heartbeat / presence topic.
pub const HEARTBEAT_TOPIC: &str = "hivemind/heartbeat/v1";
/// A2A violation proof sharing topic.
pub const A2A_VIOLATIONS_TOPIC: &str = "hivemind/a2a-violations/v1";
}
/// Port for local proof ingestion IPC (enterprise module → hivemind).
///
/// Hivemind listens on `127.0.0.1:PROOF_INGEST_PORT` for length-prefixed
/// proof envelopes from the enterprise daemon (optional) on the same machine.
pub const PROOF_INGEST_PORT: u16 = 9821;
/// Port for local IoC injection (testing/integration).
///
/// Hivemind listens on `127.0.0.1:IOC_INJECT_PORT` for length-prefixed
/// IoC JSON payloads. The injected IoC is published to GossipSub and
/// submitted to local consensus with the node's own pubkey.
pub const IOC_INJECT_PORT: u16 = 9822;
// --- Phase 1: Anti-Poisoning Constants ---
/// Initial reputation stake for new peers.
///
/// Set BELOW MIN_TRUSTED_REPUTATION so new peers must earn trust through
/// accurate reports before participating in consensus. Prevents Sybil
/// attacks where fresh peers immediately inject false IoCs.
pub const INITIAL_STAKE: u64 = 30;
/// Minimum reputation score to be considered trusted.
pub const MIN_TRUSTED_REPUTATION: u64 = 50;
/// Initial reputation stake for explicitly configured seed peers.
/// Seed peers start trusted to bootstrap the consensus network.
pub const SEED_PEER_STAKE: u64 = 100;
/// Slashing penalty for submitting a false IoC (% of stake).
pub const SLASHING_PENALTY_PERCENT: u64 = 25;
/// Reward for accurate IoC report (stake units).
pub const ACCURACY_REWARD: u64 = 5;
/// Minimum independent peer confirmations to accept an IoC.
pub const CROSS_VALIDATION_THRESHOLD: usize = 3;
/// Time window (seconds) for pending IoC cross-validation before expiry.
pub const CONSENSUS_TIMEOUT_SECS: u64 = 300;
/// Proof-of-Work difficulty for new peer registration (leading zero bits).
pub const POW_DIFFICULTY_BITS: u32 = 20;
/// Maximum new peer registrations per minute (rate limit).
pub const MAX_PEER_REGISTRATIONS_PER_MINUTE: usize = 10;
/// PoW challenge freshness window (seconds).
pub const POW_CHALLENGE_TTL_SECS: u64 = 120;
// --- Phase 1: ZKP Stub Types ---
/// A zero-knowledge proof that a threat was observed without revealing
/// raw packet data. Stub type for Phase 0-1 interface stability.
///
/// In future phases, this will contain a bellman/arkworks SNARK proof.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ThreatProof {
/// Version of the proof format (for forward compatibility).
pub version: u8,
/// The statement being proven (what claims are made).
pub statement: ProofStatement,
/// Opaque proof bytes. Empty = stub, non-empty = real SNARK proof.
pub proof_data: Vec<u8>,
/// Unix timestamp when proof was generated.
pub created_at: u64,
}
/// What a ZKP proof claims to demonstrate, without revealing private inputs.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ProofStatement {
/// JA4 fingerprint hash that was matched (public output).
pub ja4_hash: Option<[u8; 32]>,
/// Whether entropy exceeded the anomaly threshold (public output).
pub entropy_exceeded: bool,
/// Whether the behavioral classifier labeled this as malicious.
pub classified_malicious: bool,
/// IoC type this proof covers.
pub ioc_type: u8,
}
/// Proof-of-Work challenge for Sybil resistance.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PowChallenge {
/// The peer's public key (32 bytes Ed25519).
pub peer_pubkey: [u8; 32],
/// Nonce found by the peer that satisfies difficulty.
pub nonce: u64,
/// Timestamp when the PoW was computed.
pub timestamp: u64,
/// Difficulty in leading zero bits.
pub difficulty: u32,
}
/// Peer reputation record shared across the mesh.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PeerReputationRecord {
/// Ed25519 public key of the peer (32 bytes).
pub peer_pubkey: [u8; 32],
/// Current stake (starts at INITIAL_STAKE).
pub stake: u64,
/// Cumulative accuracy score (accurate reports).
pub accurate_reports: u64,
/// Count of false positives flagged by consensus.
pub false_reports: u64,
/// Unix timestamp of last activity.
pub last_active: u64,
}
// --- Phase 2: Federated Learning Constants ---
/// Interval between federated learning aggregation rounds (seconds).
pub const FL_ROUND_INTERVAL_SECS: u64 = 60;
/// Minimum peers required to run an aggregation round.
pub const FL_MIN_PEERS_PER_ROUND: usize = 3;
/// Maximum serialized gradient payload size (bytes). 16 KB.
pub const FL_MAX_GRADIENT_SIZE: usize = 16384;
/// Percentage of extreme values to trim in Byzantine-resistant FedAvg.
/// Trims top and bottom 20% of gradient contributions per dimension.
pub const FL_BYZANTINE_TRIM_PERCENT: usize = 20;
/// Feature vector dimension for the local NIDS model.
pub const FL_FEATURE_DIM: usize = 32;
/// Hidden layer size for the local NIDS model.
pub const FL_HIDDEN_DIM: usize = 16;
/// Z-score threshold × 1000 for gradient anomaly detection.
/// Value of 3000 means z-score > 3.0 triggers alarm.
pub const GRADIENT_ANOMALY_ZSCORE_THRESHOLD: u64 = 3000;
/// Maximum gradient norm (squared, integer) before rejection.
/// Prevents gradient explosion attacks.
pub const FL_MAX_GRADIENT_NORM_SQ: u64 = 1_000_000;
// --- Phase 2: Federated Learning Types ---
/// Encrypted gradient update broadcast via GossipSub.
///
/// Privacy invariant: raw gradients NEVER leave the node.
/// Only FHE-encrypted ciphertext is transmitted.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GradientUpdate {
/// Reporter's Ed25519 public key (32 bytes).
pub peer_pubkey: [u8; 32],
/// Aggregation round identifier.
pub round_id: u64,
/// FHE-encrypted gradient payload. Raw gradients NEVER transmitted.
pub encrypted_gradients: Vec<u8>,
/// Unix timestamp of gradient computation.
pub timestamp: u64,
}
/// Result of a federated aggregation round.
#[cfg(feature = "user")]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AggregatedModel {
/// Aggregation round identifier.
pub round_id: u64,
/// Aggregated model weights (after FedAvg).
pub weights: Vec<f32>,
/// Number of peers that contributed to this round.
pub participant_count: usize,
/// Unix timestamp of aggregation completion.
pub timestamp: u64,
}
// --- Phase 3: Enterprise Threat Feed Constants ---
/// Default HTTP port for the Enterprise Threat Feed API.
///
/// Uses 8090 (not 8443) because all traffic is plaintext HTTP on loopback.
/// Port 8443 conventionally implies HTTPS and would be misleading.
pub const API_DEFAULT_PORT: u16 = 8090;
/// Default API listen address.
pub const API_DEFAULT_ADDR: &str = "127.0.0.1";
/// Maximum IoCs returned per API request.
pub const API_MAX_PAGE_SIZE: usize = 1000;
/// Default IoCs per page in API responses.
pub const API_DEFAULT_PAGE_SIZE: usize = 100;
/// API key length in bytes (hex-encoded = 64 chars).
pub const API_KEY_LENGTH: usize = 32;
/// TAXII 2.1 content type header value.
pub const TAXII_CONTENT_TYPE: &str = "application/taxii+json;version=2.1";
/// STIX 2.1 content type header value.
pub const STIX_CONTENT_TYPE: &str = "application/stix+json;version=2.1";
/// TAXII collection ID for the primary threat feed.
pub const TAXII_COLLECTION_ID: &str = "hivemind-threat-feed-v1";
/// TAXII collection title.
pub const TAXII_COLLECTION_TITLE: &str = "HiveMind Verified Threat Feed";
/// STIX spec version.
pub const STIX_SPEC_VERSION: &str = "2.1";
/// Product vendor name for SIEM formats.
pub const SIEM_VENDOR: &str = "Blackwall";
/// Product name for SIEM formats.
pub const SIEM_PRODUCT: &str = "HiveMind";
/// Product version for SIEM formats.
pub const SIEM_VERSION: &str = "1.0";
/// Splunk sourcetype for HiveMind events.
pub const SPLUNK_SOURCETYPE: &str = "hivemind:threat_feed";
// --- Phase 3: Enterprise API Tier Types ---
/// API access tier determining rate limits and format availability.
#[cfg(feature = "user")]
#[derive(Copy, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ApiTier {
/// Free tier: JSON feed, limited page size.
Free,
/// Enterprise tier: all formats, full page size, STIX/TAXII.
Enterprise,
/// National security tier: full access + macro-analytics.
NationalSecurity,
}
#[cfg(feature = "user")]
impl ApiTier {
/// Maximum page size allowed for this tier.
pub fn max_page_size(self) -> usize {
match self {
ApiTier::Free => 50,
ApiTier::Enterprise => API_MAX_PAGE_SIZE,
ApiTier::NationalSecurity => API_MAX_PAGE_SIZE,
}
}
/// Whether this tier can access SIEM integration formats.
pub fn can_access_siem(self) -> bool {
matches!(self, ApiTier::Enterprise | ApiTier::NationalSecurity)
}
/// Whether this tier can access STIX/TAXII endpoints.
pub fn can_access_taxii(self) -> bool {
matches!(self, ApiTier::Enterprise | ApiTier::NationalSecurity)
}
}

562
common/src/lib.rs Executable file
View file

@ -0,0 +1,562 @@
#![cfg_attr(not(feature = "user"), no_std)]
#[cfg(feature = "user")]
pub mod base64;
pub mod hivemind;
/// Action to take on a matched rule.
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RuleAction {
/// Allow packet through
Pass = 0,
/// Drop packet silently
Drop = 1,
/// Redirect to tarpit honeypot
RedirectTarpit = 2,
}
/// Packet event emitted from eBPF via RingBuf when anomaly detected.
/// 32 bytes, naturally aligned, zero-copy safe.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct PacketEvent {
/// Source IPv4 address (network byte order)
pub src_ip: u32,
/// Destination IPv4 address (network byte order)
pub dst_ip: u32,
/// Source port (network byte order)
pub src_port: u16,
/// Destination port (network byte order)
pub dst_port: u16,
/// IP protocol number (6=TCP, 17=UDP, 1=ICMP)
pub protocol: u8,
/// TCP flags bitmask (SYN=0x02, ACK=0x10, RST=0x04, FIN=0x01)
pub flags: u8,
/// Number of payload bytes analyzed for entropy
pub payload_len: u16,
/// Byte diversity score: unique_count × 31 (range 07936).
/// NOT Shannon entropy — uses bitmap popcount heuristic in eBPF.
pub entropy_score: u32,
/// Lower 32 bits of bpf_ktime_get_ns()
pub timestamp_ns: u32,
/// Reserved padding for alignment
pub _padding: u32,
/// Total IP packet size in bytes
pub packet_size: u32,
}
/// Key for IP blocklist/allowlist HashMap.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct RuleKey {
pub ip: u32,
}
/// Value for IP blocklist/allowlist HashMap.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct RuleValue {
/// Action: 0=Pass, 1=Drop, 2=RedirectTarpit
pub action: u8,
pub _pad1: u8,
pub _pad2: u16,
/// Expiry in seconds since boot (0 = permanent)
pub expires_at: u32,
}
/// Key for LpmTrie CIDR matching.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct CidrKey {
/// Prefix length (0-32)
pub prefix_len: u32,
/// Network address (network byte order)
pub ip: u32,
}
/// Global statistics counters.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct Counters {
pub packets_total: u64,
pub packets_passed: u64,
pub packets_dropped: u64,
pub anomalies_sent: u64,
}
/// Maximum cipher suite IDs to capture from TLS ClientHello.
pub const TLS_MAX_CIPHERS: usize = 20;
/// Maximum extension IDs to capture from TLS ClientHello.
pub const TLS_MAX_EXTENSIONS: usize = 20;
/// Maximum SNI hostname bytes to capture.
pub const TLS_MAX_SNI: usize = 32;
/// TLS ClientHello raw components emitted from eBPF for JA4 assembly.
/// Contains the raw fields needed to compute JA4 fingerprint in userspace.
/// 128 bytes total, naturally aligned.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct TlsComponentsEvent {
/// Source IPv4 address (network byte order on LE host)
pub src_ip: u32,
/// Destination IPv4 address
pub dst_ip: u32,
/// Source port (host byte order)
pub src_port: u16,
/// Destination port (host byte order)
pub dst_port: u16,
/// TLS version from ClientHello (e.g., 0x0303 = TLS 1.2)
pub tls_version: u16,
/// Number of cipher suites in ClientHello
pub cipher_count: u8,
/// Number of extensions in ClientHello
pub ext_count: u8,
/// First N cipher suite IDs (network byte order)
pub ciphers: [u16; TLS_MAX_CIPHERS],
/// First N extension type IDs (network byte order)
pub extensions: [u16; TLS_MAX_EXTENSIONS],
/// SNI hostname (first 32 bytes, null-padded)
pub sni: [u8; TLS_MAX_SNI],
/// ALPN first protocol length (0 if no ALPN)
pub alpn_first_len: u8,
/// Whether SNI extension was present
pub has_sni: u8,
/// Lower 32 bits of bpf_ktime_get_ns()
pub timestamp_ns: u32,
/// Padding to 140 bytes
pub _padding: [u8; 2],
}
/// Egress event emitted from TC classifier for outbound traffic analysis.
/// 32 bytes, naturally aligned, zero-copy safe.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct EgressEvent {
/// Source IPv4 address (local server)
pub src_ip: u32,
/// Destination IPv4 address (remote)
pub dst_ip: u32,
/// Source port
pub src_port: u16,
/// Destination port
pub dst_port: u16,
/// IP protocol (6=TCP, 17=UDP)
pub protocol: u8,
/// TCP flags (if TCP)
pub flags: u8,
/// Payload length in bytes
pub payload_len: u16,
/// DNS query name length (0 if not DNS)
pub dns_query_len: u16,
/// Entropy score of outbound payload (same scale as ingress)
pub entropy_score: u16,
/// Lower 32 bits of bpf_ktime_get_ns()
pub timestamp_ns: u32,
/// Total packet size
pub packet_size: u32,
}
/// Detected protocol from DPI tail call analysis.
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum DpiProtocol {
/// Unknown protocol
Unknown = 0,
/// HTTP (detected by method keyword)
Http = 1,
/// SSH (detected by "SSH-" banner)
Ssh = 2,
/// DNS (detected by port 53 + valid structure)
Dns = 3,
/// TLS (handled separately via TlsComponentsEvent)
Tls = 4,
}
impl DpiProtocol {
/// Convert a raw u8 value to DpiProtocol.
pub fn from_u8(v: u8) -> Self {
match v {
1 => DpiProtocol::Http,
2 => DpiProtocol::Ssh,
3 => DpiProtocol::Dns,
4 => DpiProtocol::Tls,
_ => DpiProtocol::Unknown,
}
}
}
/// DPI event emitted from eBPF tail call programs via RingBuf.
/// 24 bytes, naturally aligned, zero-copy safe.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct DpiEvent {
/// Source IPv4 address
pub src_ip: u32,
/// Destination IPv4 address
pub dst_ip: u32,
/// Source port
pub src_port: u16,
/// Destination port
pub dst_port: u16,
/// Detected protocol (DpiProtocol as u8)
pub protocol: u8,
/// Protocol-specific flags (e.g., suspicious path for HTTP, tunneling for DNS)
pub flags: u8,
/// Payload length
pub payload_len: u16,
/// Lower 32 bits of bpf_ktime_get_ns()
pub timestamp_ns: u32,
}
/// DPI flags for HTTP detection.
pub const DPI_HTTP_FLAG_SUSPICIOUS_PATH: u8 = 0x01;
/// DPI flags for DNS detection.
pub const DPI_DNS_FLAG_LONG_QUERY: u8 = 0x01;
pub const DPI_DNS_FLAG_TUNNELING_SUSPECT: u8 = 0x02;
/// DPI flags for SSH detection.
pub const DPI_SSH_FLAG_SUSPICIOUS_SW: u8 = 0x01;
/// RingBuf size for DPI events (64 KB, power of 2).
pub const DPI_RINGBUF_SIZE_BYTES: u32 = 64 * 1024;
// --- eBPF Native DNAT Types ---
/// Tarpit DNAT configuration pushed from userspace into eBPF map.
/// PerCpuArray[0] — single-element scratch for tarpit routing.
/// 16 bytes, naturally aligned, zero-copy safe.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct TarpitTarget {
/// Tarpit listen port (host byte order).
pub port: u16,
/// Padding for alignment.
pub _pad: u16,
/// Local interface IP (network byte order as stored by eBPF).
/// Used for response matching in TC reverse-NAT.
pub local_ip: u32,
/// Whether DNAT is enabled (1=yes, 0=no).
pub enabled: u32,
/// Reserved for future use.
pub _reserved: u32,
}
/// NAT tracking key for tarpit DNAT connections.
/// Identifies a unique inbound flow from an attacker.
/// 8 bytes, naturally aligned.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct NatKey {
/// Attacker source IP (network byte order).
pub src_ip: u32,
/// Attacker source port (host byte order, stored as u32 for BPF alignment).
pub src_port: u32,
}
/// NAT tracking value storing the original destination before DNAT rewrite.
/// 8 bytes, naturally aligned.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct NatValue {
/// Original destination port before DNAT (host byte order).
pub orig_dst_port: u16,
pub _pad: u16,
/// Timestamp (lower 32 bits of bpf_ktime_get_ns / 1e9 for LRU approx).
pub timestamp: u32,
}
/// Connection tracking key — 5-tuple identifying a unique flow.
/// 16 bytes, naturally aligned.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct ConnTrackKey {
/// Source IP (network byte order).
pub src_ip: u32,
/// Destination IP (network byte order).
pub dst_ip: u32,
/// Source port (host byte order).
pub src_port: u16,
/// Destination port (host byte order).
pub dst_port: u16,
/// IP protocol (6=TCP, 17=UDP).
pub protocol: u8,
pub _pad: [u8; 3],
}
/// Connection tracking value — per-flow state and counters.
/// 16 bytes, naturally aligned.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct ConnTrackValue {
/// TCP state: 0=NEW, 1=SYN_SENT, 2=SYN_RECV, 3=ESTABLISHED,
/// 4=FIN_WAIT, 5=CLOSE_WAIT, 6=CLOSED
pub state: u8,
/// Cumulative TCP flags seen in this flow.
pub flags_seen: u8,
pub _pad: u16,
/// Packet count in this flow.
pub packet_count: u32,
/// Total bytes transferred.
pub byte_count: u32,
/// Timestamp of last packet (lower 32 of bpf_ktime_get_ns).
pub last_seen: u32,
}
/// Per-IP rate limit token bucket state for XDP rate limiting.
/// 8 bytes, naturally aligned.
#[repr(C)]
#[derive(Copy, Clone)]
pub struct RateLimitValue {
/// Available tokens (decremented per packet, refilled per second).
pub tokens: u32,
/// Last refill timestamp (seconds since boot, from bpf_ktime_get_boot_ns).
pub last_refill: u32,
}
/// TCP connection states for ConnTrackValue.state
pub const CT_STATE_NEW: u8 = 0;
pub const CT_STATE_SYN_SENT: u8 = 1;
pub const CT_STATE_SYN_RECV: u8 = 2;
pub const CT_STATE_ESTABLISHED: u8 = 3;
pub const CT_STATE_FIN_WAIT: u8 = 4;
pub const CT_STATE_CLOSE_WAIT: u8 = 5;
pub const CT_STATE_CLOSED: u8 = 6;
/// PROG_ARRAY indices for DPI tail call programs.
pub const DPI_PROG_HTTP: u32 = 0;
pub const DPI_PROG_DNS: u32 = 1;
pub const DPI_PROG_SSH: u32 = 2;
// --- Pod safety (aya requirement for BPF map types, userspace only) ---
// SAFETY: All types are #[repr(C)], contain only fixed-width integers,
// have no padding holes (explicit padding fields), and no pointers.
// eBPF side has no Pod trait — types just need #[repr(C)] + Copy.
#[cfg(feature = "aya")]
unsafe impl aya::Pod for PacketEvent {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for RuleKey {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for RuleValue {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for CidrKey {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for Counters {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for TlsComponentsEvent {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for EgressEvent {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for DpiEvent {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for TarpitTarget {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for NatKey {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for NatValue {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for ConnTrackKey {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for ConnTrackValue {}
#[cfg(feature = "aya")]
unsafe impl aya::Pod for RateLimitValue {}
// --- Constants ---
/// TLS record content type for Handshake.
pub const TLS_CONTENT_TYPE_HANDSHAKE: u8 = 22;
/// TLS handshake type for ClientHello.
pub const TLS_HANDSHAKE_CLIENT_HELLO: u8 = 1;
/// RingBuf size for TLS events (64 KB, power of 2).
pub const TLS_RINGBUF_SIZE_BYTES: u32 = 64 * 1024;
/// RingBuf size for egress events (64 KB, power of 2).
pub const EGRESS_RINGBUF_SIZE_BYTES: u32 = 64 * 1024;
/// DNS query name length threshold for tunneling detection.
pub const DNS_TUNNEL_QUERY_LEN_THRESHOLD: u16 = 200;
/// Byte diversity threshold. Payloads above this → anomaly event.
/// Scale: unique_count × 31 (encrypted traffic: ~70007936, ASCII: ~12001800).
pub const ENTROPY_ANOMALY_THRESHOLD: u32 = 6500;
/// Maximum payload bytes to analyze for entropy (must fit in eBPF bounded loop).
pub const MAX_PAYLOAD_ANALYSIS_BYTES: usize = 128;
/// RingBuf size in bytes (must be power of 2). 256 KB.
pub const RINGBUF_SIZE_BYTES: u32 = 256 * 1024;
/// Maximum entries in IP blocklist HashMap.
pub const BLOCKLIST_MAX_ENTRIES: u32 = 65536;
/// Maximum entries in CIDR LpmTrie.
pub const CIDR_MAX_ENTRIES: u32 = 4096;
/// Maximum entries in NAT tracking table (per-connection tarpit DNAT).
pub const NAT_TABLE_MAX_ENTRIES: u32 = 65536;
/// Maximum entries in connection tracking LRU map.
pub const CONN_TRACK_MAX_ENTRIES: u32 = 131072;
/// Maximum entries in per-IP rate limit LRU map.
pub const RATE_LIMIT_MAX_ENTRIES: u32 = 131072;
/// Rate limit: max packets per second per IP before XDP_DROP.
pub const RATE_LIMIT_PPS: u32 = 100;
/// Rate limit: burst capacity (tokens). Allows short bursts above PPS.
pub const RATE_LIMIT_BURST: u32 = 200;
/// Tarpit default port.
pub const TARPIT_PORT: u16 = 2222;
/// Tarpit base delay milliseconds.
pub const TARPIT_BASE_DELAY_MS: u64 = 50;
/// Tarpit max delay milliseconds.
pub const TARPIT_MAX_DELAY_MS: u64 = 500;
/// Tarpit jitter range milliseconds.
pub const TARPIT_JITTER_MS: u64 = 100;
/// Tarpit min chunk size (bytes).
pub const TARPIT_MIN_CHUNK: usize = 1;
/// Tarpit max chunk size (bytes).
pub const TARPIT_MAX_CHUNK: usize = 15;
// --- Helper functions (std-only) ---
#[cfg(feature = "user")]
pub mod util {
use core::net::Ipv4Addr;
/// Convert u32 (network byte order stored on LE host) to displayable IPv4.
///
/// eBPF reads IP header fields as raw u32 on bpfel (little-endian).
/// The wire bytes [A,B,C,D] become a LE u32 value. `u32::from_be()`
/// converts that to a host-order value that `Ipv4Addr::from(u32)` expects.
pub fn ip_from_u32(ip: u32) -> Ipv4Addr {
Ipv4Addr::from(u32::from_be(ip))
}
/// Convert IPv4 to u32 matching eBPF's bpfel representation.
///
/// `Ipv4Addr → u32` yields a host-order value (MSB = first octet).
/// `.to_be()` converts to the same representation eBPF stores.
pub fn ip_to_u32(ip: Ipv4Addr) -> u32 {
u32::from(ip).to_be()
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
use core::mem;
#[test]
fn packet_event_size_and_alignment() {
assert_eq!(mem::size_of::<PacketEvent>(), 32);
assert_eq!(mem::align_of::<PacketEvent>(), 4);
}
#[test]
fn rule_key_size() {
assert_eq!(mem::size_of::<RuleKey>(), 4);
}
#[test]
fn rule_value_size() {
assert_eq!(mem::size_of::<RuleValue>(), 8);
}
#[test]
fn cidr_key_size() {
assert_eq!(mem::size_of::<CidrKey>(), 8);
}
#[test]
fn counters_size() {
assert_eq!(mem::size_of::<Counters>(), 32);
}
#[test]
fn tls_components_event_size() {
assert_eq!(mem::size_of::<TlsComponentsEvent>(), 140);
}
#[test]
fn tls_components_event_alignment() {
assert_eq!(mem::align_of::<TlsComponentsEvent>(), 4);
}
#[test]
fn egress_event_size() {
assert_eq!(mem::size_of::<EgressEvent>(), 28);
}
#[test]
fn egress_event_alignment() {
assert_eq!(mem::align_of::<EgressEvent>(), 4);
}
#[test]
fn entropy_threshold_in_range() {
assert!(ENTROPY_ANOMALY_THRESHOLD <= 8000);
assert!(ENTROPY_ANOMALY_THRESHOLD > 0);
}
#[test]
fn ringbuf_size_is_power_of_two() {
assert!(RINGBUF_SIZE_BYTES.is_power_of_two());
}
#[test]
fn ip_conversion_roundtrip() {
use util::*;
let ip = core::net::Ipv4Addr::new(192, 168, 1, 1);
let raw = ip_to_u32(ip);
assert_eq!(ip_from_u32(raw), ip);
}
#[test]
fn dpi_event_size() {
assert_eq!(mem::size_of::<DpiEvent>(), 20);
}
#[test]
fn dpi_event_alignment() {
assert_eq!(mem::align_of::<DpiEvent>(), 4);
}
#[test]
fn tarpit_target_size() {
assert_eq!(mem::size_of::<TarpitTarget>(), 16);
}
#[test]
fn nat_key_size() {
assert_eq!(mem::size_of::<NatKey>(), 8);
}
#[test]
fn nat_value_size() {
assert_eq!(mem::size_of::<NatValue>(), 8);
}
#[test]
fn conn_track_key_size() {
assert_eq!(mem::size_of::<ConnTrackKey>(), 16);
}
#[test]
fn conn_track_value_size() {
assert_eq!(mem::size_of::<ConnTrackValue>(), 16);
}
}

43
config.toml.example Executable file
View file

@ -0,0 +1,43 @@
# Blackwall — Example Configuration
# Copy to config.toml and adjust for your environment.
[network]
# Network interface to attach XDP program to
interface = "eth0"
# XDP attach mode: "generic", "native", or "offload"
xdp_mode = "generic"
[thresholds]
# Byte diversity score above which a packet is anomalous (range 07936)
entropy_anomaly = 6000
[tarpit]
enabled = true
# Port the tarpit honeypot listens on
port = 2222
# Jitter parameters (milliseconds)
base_delay_ms = 100
max_delay_ms = 30000
jitter_ms = 500
[ai]
enabled = true
# Ollama API endpoint
ollama_url = "http://localhost:11434"
# Primary and fallback LLM models (must be ≤3B params for 8GB VRAM)
model = "qwen3:1.7b"
fallback_model = "qwen3:0.6b"
# Max tokens for classification response
max_tokens = 512
# Timeout for LLM requests (milliseconds)
timeout_ms = 5000
[rules]
# Static blocklist — IPs to always DROP
blocklist = [
# "192.168.1.100",
]
# Static allowlist — IPs to always PASS
allowlist = [
"127.0.0.1",
]

View file

@ -0,0 +1,20 @@
# Blackwall userspace daemon — multi-stage build
# Stage 1: Build the Rust binary
FROM rust:1.87-bookworm AS builder
WORKDIR /build
COPY . .
RUN cargo build --release --bin blackwall \
&& strip target/release/blackwall
# Stage 2: Minimal runtime image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
iproute2 \
libelf1 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/blackwall /usr/local/bin/blackwall
RUN useradd -r -s /usr/sbin/nologin blackwall
# eBPF requires root/CAP_BPF — runs as root in container, limited by securityContext
ENTRYPOINT ["/usr/local/bin/blackwall"]
CMD ["/etc/blackwall/config.toml"]

16
deploy/docker/Dockerfile.ebpf Executable file
View file

@ -0,0 +1,16 @@
# Blackwall eBPF programs — init container
# Builds the BPF object file with nightly + bpfel target
FROM rust:1.87-bookworm AS builder
RUN rustup toolchain install nightly \
&& rustup component add rust-src --toolchain nightly
WORKDIR /build
COPY . .
RUN cd blackwall-ebpf && \
cargo +nightly build \
--target bpfel-unknown-none \
-Z build-std=core \
--release
# Stage 2: Tiny image with just the BPF binary
FROM busybox:1.37
COPY --from=builder /build/target/bpfel-unknown-none/release/blackwall-ebpf /opt/blackwall/blackwall-ebpf

View file

@ -0,0 +1,34 @@
# Example BlackwallPolicy — drop known bad IPs, tarpit scanners
apiVersion: security.blackwall.io/v1alpha1
kind: BlackwallPolicy
metadata:
name: default-policy
namespace: blackwall-system
spec:
rules:
blocklist:
- ip: "192.168.1.100"
action: drop
duration: "1h"
- ip: "10.0.0.0/8"
action: tarpit
- ip: "203.0.113.0/24"
action: drop
allowlist:
- ip: "192.168.0.0/16"
reason: "internal network"
thresholds:
entropyAnomaly: 6500
synFloodRate: 1000
tarpit:
enabled: true
port: 2222
baseDelayMs: 100
maxDelayMs: 30000
ai:
enabled: true
model: "qwen3:1.7b"
fallbackModel: "qwen3:0.6b"
network:
interface: "auto"
xdpMode: "native"

61
deploy/healthcheck.sh Executable file
View file

@ -0,0 +1,61 @@
#!/bin/bash
# Blackwall Health Check — returns non-zero if any component is down
FAILED=0
REPORT=""
# Check blackwall daemon
if ! pidof blackwall > /dev/null 2>&1; then
REPORT+="CRIT: blackwall not running\n"
FAILED=1
fi
# Check XDP attached
if ! ip link show | grep -q xdp; then
REPORT+="CRIT: XDP not attached to any interface\n"
FAILED=1
fi
# Check tarpit
if ! pidof tarpit > /dev/null 2>&1; then
REPORT+="WARN: tarpit not running\n"
fi
# Check hivemind
if ! pidof hivemind > /dev/null 2>&1; then
REPORT+="WARN: hivemind not running\n"
fi
# Check hivemind-api
if ! ss -tlnp | grep -q 8090; then
REPORT+="WARN: hivemind-api not listening on 8090\n"
fi
# Check peer connectivity (if hivemind-api responds)
STATS=$(curl -s --max-time 3 http://127.0.0.1:8090/stats 2>/dev/null)
if [ -n "$STATS" ]; then
PEERS=$(echo "$STATS" | grep -o '"peer_count":[0-9]*' | cut -d: -f2)
if [ "${PEERS:-0}" -eq 0 ]; then
REPORT+="WARN: hivemind has 0 peers\n"
fi
fi
# Check Docker (if applicable)
if command -v docker &> /dev/null; then
DOCKER_COUNT=$(docker ps -q 2>/dev/null | wc -l)
if [ "$DOCKER_COUNT" -eq 0 ]; then
REPORT+="CRIT: No Docker containers running (expected >0)\n"
FAILED=1
fi
fi
if [ $FAILED -eq 1 ]; then
echo -e "BLACKWALL HEALTH: CRITICAL\n$REPORT"
exit 1
elif [ -n "$REPORT" ]; then
echo -e "BLACKWALL HEALTH: DEGRADED\n$REPORT"
exit 0
else
echo "BLACKWALL HEALTH: OK"
exit 0
fi

View file

@ -0,0 +1,16 @@
apiVersion: v2
name: blackwall
description: eBPF-powered AI firewall with XDP native DNAT, TCP tarpit deception, and P2P threat intelligence
type: application
version: 0.1.0
appVersion: "2.0.0"
keywords:
- ebpf
- xdp
- firewall
- honeypot
- ai
- security
maintainers:
- name: Blackwall Team
home: https://github.com/blackwall-fw/blackwall

View file

@ -0,0 +1,36 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
data:
config.toml: |
[network]
interface = {{ .Values.network.interface | quote }}
xdp_mode = {{ .Values.network.xdpMode | quote }}
[tarpit]
enabled = {{ .Values.tarpit.enabled }}
port = {{ .Values.tarpit.port }}
base_delay_ms = {{ .Values.tarpit.baseDelayMs }}
max_delay_ms = {{ .Values.tarpit.maxDelayMs }}
jitter_ms = {{ .Values.tarpit.jitterMs }}
[ai]
enabled = {{ .Values.ai.enabled }}
ollama_url = {{ .Values.ai.ollamaUrl | quote }}
model = {{ .Values.ai.model | quote }}
fallback_model = {{ .Values.ai.fallbackModel | quote }}
max_tokens = {{ .Values.ai.maxTokens }}
timeout_ms = {{ .Values.ai.timeoutMs }}
[feeds]
enabled = {{ .Values.feeds.enabled }}
refresh_interval_secs = {{ .Values.feeds.refreshIntervalSecs }}
[metrics]
enabled = {{ .Values.metrics.enabled }}
port = {{ .Values.metrics.port }}

View file

@ -0,0 +1,137 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: blackwallpolicies.security.blackwall.io
labels:
app.kubernetes.io/name: blackwall
spec:
group: security.blackwall.io
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
# IP-based rules
rules:
type: object
properties:
blocklist:
type: array
items:
type: object
properties:
ip:
type: string
pattern: '^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$'
action:
type: string
enum: ["drop", "tarpit", "pass"]
duration:
type: string
pattern: '^\d+[smh]$'
description: "Block duration (e.g., 10m, 1h, 30s)"
required: ["ip", "action"]
allowlist:
type: array
items:
type: object
properties:
ip:
type: string
reason:
type: string
required: ["ip"]
# Anomaly detection thresholds
thresholds:
type: object
properties:
entropyAnomaly:
type: integer
minimum: 0
maximum: 8000
description: "Byte diversity score above which packets are flagged"
synFloodRate:
type: integer
minimum: 0
description: "SYN packets per second before triggering protection"
# Tarpit configuration
tarpit:
type: object
properties:
enabled:
type: boolean
port:
type: integer
minimum: 1
maximum: 65535
baseDelayMs:
type: integer
maxDelayMs:
type: integer
# AI classification
ai:
type: object
properties:
enabled:
type: boolean
model:
type: string
fallbackModel:
type: string
# Interface selection (per-node override via nodeSelector)
network:
type: object
properties:
interface:
type: string
xdpMode:
type: string
enum: ["generic", "native", "offload"]
status:
type: object
properties:
conditions:
type: array
items:
type: object
properties:
type:
type: string
status:
type: string
lastTransitionTime:
type: string
format: date-time
message:
type: string
appliedNodes:
type: integer
description: "Number of nodes where policy is active"
blockedIPs:
type: integer
description: "Total IPs currently in blocklist across cluster"
subresources:
status: {}
additionalPrinterColumns:
- name: Rules
type: integer
jsonPath: .status.blockedIPs
- name: Nodes
type: integer
jsonPath: .status.appliedNodes
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
scope: Namespaced
names:
plural: blackwallpolicies
singular: blackwallpolicy
kind: BlackwallPolicy
shortNames:
- bwp

View file

@ -0,0 +1,119 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: {{ .Release.Name }}-blackwall
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/component: dataplane
spec:
selector:
matchLabels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: {{ .Values.metrics.port | quote }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ .Values.serviceAccount.name }}
{{- end }}
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
# Copy pre-built eBPF object into shared volume
- name: ebpf-loader
image: "{{ .Values.ebpfImage.repository }}:{{ .Values.ebpfImage.tag }}"
imagePullPolicy: {{ .Values.ebpfImage.pullPolicy }}
command: ["cp", "/opt/blackwall/blackwall-ebpf", "/bpf/blackwall-ebpf"]
volumeMounts:
- name: bpf-objects
mountPath: /bpf
containers:
- name: blackwall
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- "/etc/blackwall/config.toml"
env:
- name: BLACKWALL_EBPF_PATH
value: "/bpf/blackwall-ebpf"
- name: RUST_LOG
value: "blackwall=info"
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: bpf-objects
mountPath: /bpf
readOnly: true
- name: config
mountPath: /etc/blackwall
readOnly: true
- name: bpf-fs
mountPath: /sys/fs/bpf
{{- if .Values.pcap.enabled }}
- name: pcap-storage
mountPath: /var/lib/blackwall/pcap
{{- end }}
ports:
{{- if .Values.tarpit.enabled }}
- name: tarpit
containerPort: {{ .Values.tarpit.port }}
protocol: TCP
hostPort: {{ .Values.tarpit.port }}
{{- end }}
{{- if .Values.metrics.enabled }}
- name: metrics
containerPort: {{ .Values.metrics.port }}
protocol: TCP
{{- end }}
livenessProbe:
exec:
command: ["cat", "/proc/1/status"]
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
exec:
command: ["test", "-f", "/sys/fs/bpf/blackwall_xdp"]
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: bpf-objects
emptyDir: {}
- name: config
configMap:
name: {{ .Release.Name }}-config
- name: bpf-fs
hostPath:
path: /sys/fs/bpf
type: DirectoryOrCreate
{{- if .Values.pcap.enabled }}
- name: pcap-storage
persistentVolumeClaim:
claimName: {{ .Release.Name }}-pcap
{{- end }}

View file

@ -0,0 +1,39 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ .Release.Name }}-blackwall
labels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
rules:
# Watch BlackwallPolicy CRDs
- apiGroups: ["security.blackwall.io"]
resources: ["blackwallpolicies"]
verbs: ["get", "list", "watch"]
- apiGroups: ["security.blackwall.io"]
resources: ["blackwallpolicies/status"]
verbs: ["patch", "update"]
# Read node info for interface auto-detection
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list"]
# ConfigMaps for config
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ .Release.Name }}-blackwall
labels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ .Release.Name }}-blackwall
subjects:
- kind: ServiceAccount
name: {{ .Values.serviceAccount.name }}
namespace: {{ .Release.Namespace }}

View file

@ -0,0 +1,14 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Values.serviceAccount.name }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: blackwall
app.kubernetes.io/instance: {{ .Release.Name }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,92 @@
# Blackwall Helm Chart — Default Values
# Override per environment via -f values-production.yaml
# Container image
image:
repository: ghcr.io/blackwall-fw/blackwall
tag: "2.0.0"
pullPolicy: IfNotPresent
# eBPF image (init container builds/copies the BPF object)
ebpfImage:
repository: ghcr.io/blackwall-fw/blackwall-ebpf
tag: "2.0.0"
pullPolicy: IfNotPresent
# DaemonSet scheduling
nodeSelector: {}
tolerations:
- key: node-role.kubernetes.io/master
effect: NoSchedule
affinity: {}
# Resource limits
resources:
limits:
cpu: "500m"
memory: "256Mi"
requests:
cpu: "100m"
memory: "128Mi"
# Network configuration
network:
# Interface to attach XDP program to.
# "auto" = detect primary interface via default route.
interface: "auto"
# XDP attach mode: generic, native, offload
xdpMode: "generic"
# Tarpit honeypot
tarpit:
enabled: true
port: 2222
baseDelayMs: 100
maxDelayMs: 30000
jitterMs: 500
# AI/LLM classification
ai:
enabled: true
ollamaUrl: "http://ollama.default.svc.cluster.local:11434"
model: "qwen3:1.7b"
fallbackModel: "qwen3:0.6b"
maxTokens: 512
timeoutMs: 30000
# Threat feeds
feeds:
enabled: true
refreshIntervalSecs: 3600
# HiveMind P2P mesh
hivemind:
enabled: false
bootstrapPeers: []
# PCAP forensic capture
pcap:
enabled: false
storageClass: ""
storageSize: "10Gi"
# Metrics (Prometheus)
metrics:
enabled: true
port: 9090
# ServiceAccount
serviceAccount:
create: true
name: "blackwall"
annotations: {}
# Security context (eBPF requires CAP_BPF + CAP_NET_ADMIN)
securityContext:
privileged: false
capabilities:
add:
- BPF
- NET_ADMIN
- SYS_ADMIN
- PERFMON

View file

@ -0,0 +1,14 @@
[Unit]
Description=Blackwall HiveMind API
After=blackwall-hivemind.service
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/bin/hivemind-api
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,8 @@
[Unit]
Description=Blackwall health check
[Service]
Type=oneshot
ExecStart=/opt/blackwall/healthcheck.sh
StandardOutput=append:/var/log/blackwall-health.log
StandardError=append:/var/log/blackwall-health.log

View file

@ -0,0 +1,10 @@
[Unit]
Description=Blackwall health check timer (every 5 min)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=Blackwall HiveMind P2P Mesh
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/bin/hivemind
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=Blackwall Tarpit Honeypot
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/bin/tarpit
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,18 @@
[Unit]
Description=Blackwall Adaptive eBPF Firewall
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/bin/blackwall /opt/blackwall/config.toml
Environment=BLACKWALL_EBPF_PATH=/opt/blackwall/bin/blackwall-ebpf
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=5
LimitMEMLOCK=infinity
AmbientCapabilities=CAP_BPF CAP_NET_ADMIN CAP_PERFMON CAP_SYS_PTRACE
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=Blackwall HiveMind API
After=blackwall-hivemind.service
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/hivemind-api
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=Blackwall HiveMind P2P Mesh
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/usr/local/bin/hivemind
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=Blackwall Tarpit Honeypot
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/tarpit
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,18 @@
[Unit]
Description=Blackwall Adaptive eBPF Firewall
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/blackwall
ExecStart=/opt/blackwall/blackwall /opt/blackwall/config.toml
Environment=BLACKWALL_EBPF_PATH=/opt/blackwall/blackwall-ebpf
Environment=RUST_LOG=info
Restart=on-failure
RestartSec=5
LimitMEMLOCK=infinity
AmbientCapabilities=CAP_BPF CAP_NET_ADMIN CAP_PERFMON CAP_SYS_PTRACE
[Install]
WantedBy=multi-user.target

22
hivemind-api/Cargo.toml Executable file
View file

@ -0,0 +1,22 @@
[package]
name = "hivemind-api"
version = "0.1.0"
edition = "2021"
description = "Enterprise Threat Feed API — REST/STIX/TAXII endpoint for HiveMind verified IoCs"
[dependencies]
common = { path = "../common", default-features = false, features = ["user"] }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
hyper = { workspace = true, features = ["server"] }
hyper-util = { workspace = true }
http-body-util = { workspace = true }
ring = { workspace = true }
[[bin]]
name = "hivemind-api"
path = "src/main.rs"

305
hivemind-api/src/feed.rs Executable file
View file

@ -0,0 +1,305 @@
/// Feed query parameter parsing and response formatting.
///
/// Bridges the HTTP layer (server.rs) to the storage layer (store.rs)
/// by parsing URL query parameters into `QueryParams` and formatting
/// paginated feed results as JSON responses.
use common::hivemind;
use crate::store::QueryParams;
/// Parse query parameters from a URI query string.
///
/// Supported parameters:
/// - `since` — Unix timestamp filter (only IoCs verified after this time)
/// - `severity` — Minimum severity level (0-4)
/// - `type` — IoC type filter (0-4)
/// - `limit` — Page size (capped by tier max)
/// - `offset` — Pagination offset
///
/// Invalid parameter values are silently ignored (defaults used).
pub fn parse_query_params(query: Option<&str>, max_page_size: usize) -> QueryParams {
let mut params = QueryParams::new();
params.limit = params.limit.min(max_page_size);
let query = match query {
Some(q) => q,
None => return params,
};
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
let key = match parts.next() {
Some(k) => k,
None => continue,
};
let value = match parts.next() {
Some(v) => v,
None => continue,
};
match key {
"since" => {
if let Ok(ts) = value.parse::<u64>() {
params.since = Some(ts);
}
}
"severity" => {
if let Ok(sev) = value.parse::<u8>() {
if sev <= 4 {
params.min_severity = Some(sev);
}
}
}
"type" => {
if let Ok(t) = value.parse::<u8>() {
if t <= 4 {
params.ioc_type = Some(t);
}
}
}
"limit" => {
if let Ok(l) = value.parse::<usize>() {
params.limit = l.min(max_page_size).max(1);
}
}
"offset" => {
if let Ok(o) = value.parse::<usize>() {
params.offset = o;
}
}
_ => {} // Unknown params silently ignored
}
}
params
}
/// Feed statistics for the /api/v1/stats endpoint.
#[derive(Clone, Debug, serde::Serialize)]
pub struct FeedStats {
/// Total verified IoCs in the feed.
pub total_iocs: usize,
/// Breakdown by severity level.
pub by_severity: SeverityBreakdown,
/// Breakdown by IoC type.
pub by_type: TypeBreakdown,
}
/// Count of IoCs per severity level.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct SeverityBreakdown {
pub info: usize,
pub low: usize,
pub medium: usize,
pub high: usize,
pub critical: usize,
}
/// Count of IoCs per type.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct TypeBreakdown {
pub malicious_ip: usize,
pub ja4_fingerprint: usize,
pub entropy_anomaly: usize,
pub dns_tunnel: usize,
pub behavioral_pattern: usize,
}
/// Compute feed statistics from the store.
pub fn compute_stats(store: &crate::store::ThreatFeedStore) -> FeedStats {
let all = store.all();
let mut by_severity = SeverityBreakdown::default();
let mut by_type = TypeBreakdown::default();
for vioc in all {
match hivemind::ThreatSeverity::from_u8(vioc.ioc.severity) {
hivemind::ThreatSeverity::Info => by_severity.info += 1,
hivemind::ThreatSeverity::Low => by_severity.low += 1,
hivemind::ThreatSeverity::Medium => by_severity.medium += 1,
hivemind::ThreatSeverity::High => by_severity.high += 1,
hivemind::ThreatSeverity::Critical => by_severity.critical += 1,
}
match hivemind::IoCType::from_u8(vioc.ioc.ioc_type) {
hivemind::IoCType::MaliciousIp => by_type.malicious_ip += 1,
hivemind::IoCType::Ja4Fingerprint => by_type.ja4_fingerprint += 1,
hivemind::IoCType::EntropyAnomaly => by_type.entropy_anomaly += 1,
hivemind::IoCType::DnsTunnel => by_type.dns_tunnel += 1,
hivemind::IoCType::BehavioralPattern => by_type.behavioral_pattern += 1,
}
}
FeedStats {
total_iocs: all.len(),
by_severity,
by_type,
}
}
/// Mesh stats compatible with the TUI dashboard's MeshStats struct.
///
/// Returns IoC counts mapped to the dashboard's expected fields.
#[derive(Clone, Debug, serde::Serialize)]
pub struct DashboardMeshStats {
pub connected: bool,
// P2P Mesh
pub peer_count: u64,
pub dht_records: u64,
pub gossip_topics: u64,
pub messages_per_sec: f64,
// Threat Intel
pub iocs_shared: u64,
pub iocs_received: u64,
pub avg_reputation: f64,
// Network Firewall (XDP/eBPF)
pub packets_total: u64,
pub packets_passed: u64,
pub packets_dropped: u64,
pub anomalies_sent: u64,
// A2A Firewall (separate from XDP)
pub a2a_jwts_verified: u64,
pub a2a_violations: u64,
pub a2a_injections: u64,
// Cryptography
pub zkp_proofs_generated: u64,
pub zkp_proofs_verified: u64,
pub fhe_encrypted: bool,
}
/// Compute dashboard-compatible mesh stats from the store.
pub fn compute_mesh_stats(store: &crate::store::ThreatFeedStore, counters: &crate::server::HivemindCounters) -> DashboardMeshStats {
use std::sync::atomic::Ordering;
let all = store.all();
let total = all.len() as u64;
// eBPF/XDP counters
let pkt_total = counters.packets_total.load(Ordering::Relaxed);
let pkt_passed = counters.packets_passed.load(Ordering::Relaxed);
let pkt_dropped = counters.packets_dropped.load(Ordering::Relaxed);
let anomalies = counters.anomalies_sent.load(Ordering::Relaxed);
// P2P counters
let peers = counters.peer_count.load(Ordering::Relaxed);
let iocs_p2p = counters.iocs_shared_p2p.load(Ordering::Relaxed);
let rep_x100 = counters.avg_reputation_x100.load(Ordering::Relaxed);
let msgs_total = counters.messages_total.load(Ordering::Relaxed);
// A2A counters
let a2a_jwts = counters.a2a_jwts_verified.load(Ordering::Relaxed);
let a2a_viol = counters.a2a_violations.load(Ordering::Relaxed);
let a2a_inj = counters.a2a_injections.load(Ordering::Relaxed);
DashboardMeshStats {
connected: true,
peer_count: peers,
dht_records: total,
gossip_topics: if total > 0 || peers > 0 { 1 } else { 0 },
messages_per_sec: msgs_total as f64 / 60.0,
iocs_shared: iocs_p2p,
iocs_received: pkt_total,
avg_reputation: rep_x100 as f64 / 100.0,
packets_total: pkt_total,
packets_passed: pkt_passed,
packets_dropped: pkt_dropped,
anomalies_sent: anomalies,
a2a_jwts_verified: a2a_jwts,
a2a_violations: a2a_viol,
a2a_injections: a2a_inj,
zkp_proofs_generated: 0,
zkp_proofs_verified: 0,
fhe_encrypted: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_query() {
let params = parse_query_params(None, 1000);
assert_eq!(params.limit, hivemind::API_DEFAULT_PAGE_SIZE);
assert_eq!(params.offset, 0);
assert!(params.since.is_none());
assert!(params.min_severity.is_none());
assert!(params.ioc_type.is_none());
}
#[test]
fn parse_all_params() {
let params = parse_query_params(
Some("since=1700000000&severity=3&type=1&limit=50&offset=10"),
1000,
);
assert_eq!(params.since, Some(1700000000));
assert_eq!(params.min_severity, Some(3));
assert_eq!(params.ioc_type, Some(1));
assert_eq!(params.limit, 50);
assert_eq!(params.offset, 10);
}
#[test]
fn limit_capped_by_tier() {
let params = parse_query_params(Some("limit=5000"), 100);
assert_eq!(params.limit, 100);
}
#[test]
fn invalid_params_ignored() {
let params = parse_query_params(
Some("since=notanumber&severity=99&limit=abc&unknown=foo"),
1000,
);
assert!(params.since.is_none());
assert!(params.min_severity.is_none()); // 99 > 4, ignored
assert_eq!(params.limit, hivemind::API_DEFAULT_PAGE_SIZE);
}
#[test]
fn compute_stats_populated() {
use crate::store::ThreatFeedStore;
use common::hivemind::IoC;
let mut store = ThreatFeedStore::new();
store.insert(
IoC {
ioc_type: 0,
severity: 3,
ip: 1,
ja4: None,
entropy_score: None,
description: "test".to_string(),
first_seen: 1000,
confirmations: 3,
zkp_proof: Vec::new(),
},
2000,
);
store.insert(
IoC {
ioc_type: 1,
severity: 4,
ip: 2,
ja4: None,
entropy_score: None,
description: "test2".to_string(),
first_seen: 1000,
confirmations: 3,
zkp_proof: Vec::new(),
},
3000,
);
let stats = compute_stats(&store);
assert_eq!(stats.total_iocs, 2);
assert_eq!(stats.by_severity.high, 1);
assert_eq!(stats.by_severity.critical, 1);
assert_eq!(stats.by_type.malicious_ip, 1);
assert_eq!(stats.by_type.ja4_fingerprint, 1);
}
}

View file

@ -0,0 +1,187 @@
/// ArcSight Common Event Format (CEF) exporter.
///
/// Converts verified IoCs to CEF format for ingestion by ArcSight,
/// Sentinel, and other SIEM platforms that support CEF.
///
/// Format: `CEF:0|Vendor|Product|Version|SignatureID|Name|Severity|Extensions`
///
/// Reference: <https://www.microfocus.com/documentation/arcsight/arcsight-smartconnectors/pdfdoc/cef-implementation-standard/cef-implementation-standard.pdf>
use common::hivemind::{self, IoCType, ThreatSeverity};
use crate::store::{ip_to_string, unix_to_iso8601, VerifiedIoC};
/// Convert a verified IoC to a CEF format string.
///
/// The returned string is a single CEF event line.
pub fn ioc_to_cef(vioc: &VerifiedIoC) -> String {
let ioc = &vioc.ioc;
let ioc_type = IoCType::from_u8(ioc.ioc_type);
let severity = ThreatSeverity::from_u8(ioc.severity);
let sig_id = cef_signature_id(ioc_type);
let name = escape_cef_header(&cef_event_name(ioc_type, ioc));
let sev = cef_severity(severity);
// CEF header (pipe-delimited, 7 fields after CEF:0)
let header = format!(
"CEF:0|{}|{}|{}|{sig_id}|{name}|{sev}",
escape_cef_header(hivemind::SIEM_VENDOR),
escape_cef_header(hivemind::SIEM_PRODUCT),
escape_cef_header(hivemind::SIEM_VERSION),
);
// CEF extensions (key=value space-delimited)
let src_ip = ip_to_string(ioc.ip);
let timestamp = unix_to_iso8601(vioc.verified_at);
let mut ext = format!(
"src={src_ip} rt={timestamp} cat={} msg={} cs1Label=stix_id cs1={} \
cn1Label=confirmations cn1={}",
escape_cef_value(cef_category(ioc_type)),
escape_cef_value(&ioc.description),
escape_cef_value(&vioc.stix_id),
ioc.confirmations,
);
if let Some(ref ja4) = ioc.ja4 {
ext.push_str(&format!(
" cs2Label=ja4 cs2={}",
escape_cef_value(ja4)
));
}
if let Some(entropy) = ioc.entropy_score {
ext.push_str(&format!(" cn2Label=entropy_score cn2={entropy}"));
}
format!("{header}|{ext}")
}
/// Convert a batch of verified IoCs to newline-delimited CEF.
pub fn batch_to_cef(iocs: &[VerifiedIoC]) -> String {
iocs.iter()
.map(ioc_to_cef)
.collect::<Vec<_>>()
.join("\n")
}
/// Map IoC type to CEF signature ID.
fn cef_signature_id(t: IoCType) -> u16 {
match t {
IoCType::MaliciousIp => 1001,
IoCType::Ja4Fingerprint => 1002,
IoCType::EntropyAnomaly => 1003,
IoCType::DnsTunnel => 1004,
IoCType::BehavioralPattern => 1005,
}
}
/// Build human-readable CEF event name.
fn cef_event_name(t: IoCType, ioc: &common::hivemind::IoC) -> String {
match t {
IoCType::MaliciousIp => format!("Malicious IP {}", ip_to_string(ioc.ip)),
IoCType::Ja4Fingerprint => "Malicious TLS Fingerprint".to_string(),
IoCType::EntropyAnomaly => "High Entropy Anomaly".to_string(),
IoCType::DnsTunnel => "DNS Tunneling Detected".to_string(),
IoCType::BehavioralPattern => "Behavioral Anomaly".to_string(),
}
}
/// Map threat severity to CEF severity (0-10).
fn cef_severity(s: ThreatSeverity) -> u8 {
match s {
ThreatSeverity::Info => 1,
ThreatSeverity::Low => 3,
ThreatSeverity::Medium => 5,
ThreatSeverity::High => 8,
ThreatSeverity::Critical => 10,
}
}
/// Map IoC type to CEF category string.
fn cef_category(t: IoCType) -> &'static str {
match t {
IoCType::MaliciousIp => "Threat/MaliciousIP",
IoCType::Ja4Fingerprint => "Threat/TLSFingerprint",
IoCType::EntropyAnomaly => "Anomaly/Entropy",
IoCType::DnsTunnel => "Threat/DNSTunnel",
IoCType::BehavioralPattern => "Anomaly/Behavioral",
}
}
/// Escape pipe characters in CEF header fields.
///
/// CEF uses `|` as the header delimiter — pipes must be escaped as `\|`.
fn escape_cef_header(s: &str) -> String {
s.replace('\\', "\\\\").replace('|', "\\|")
}
/// Escape special characters in CEF extension values.
///
/// Backslash, equals, and newlines must be escaped in extension values.
fn escape_cef_value(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('=', "\\=")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
#[cfg(test)]
mod tests {
use super::*;
use common::hivemind::IoC;
fn sample_vioc() -> VerifiedIoC {
VerifiedIoC {
ioc: IoC {
ioc_type: 0,
severity: 4,
ip: 0x0A000001,
ja4: Some("t13d".to_string()),
entropy_score: Some(8000),
description: "Critical threat".to_string(),
first_seen: 1700000000,
confirmations: 5,
zkp_proof: Vec::new(),
},
verified_at: 1700001000,
stix_id: "indicator--test".to_string(),
}
}
#[test]
fn cef_format_structure() {
let vioc = sample_vioc();
let cef = ioc_to_cef(&vioc);
// CEF header has 8 pipe-delimited fields
let parts: Vec<&str> = cef.splitn(8, '|').collect();
assert_eq!(parts.len(), 8);
assert_eq!(parts[0], "CEF:0");
assert_eq!(parts[1], "Blackwall");
assert_eq!(parts[2], "HiveMind");
assert_eq!(parts[3], "1.0");
assert_eq!(parts[4], "1001"); // MaliciousIp signature ID
assert!(parts[5].contains("10.0.0.1"));
assert_eq!(parts[6], "10"); // Critical severity
}
#[test]
fn cef_escapes_pipes() {
assert_eq!(escape_cef_header("test|pipe"), "test\\|pipe");
assert_eq!(escape_cef_header("back\\slash"), "back\\\\slash");
}
#[test]
fn cef_escapes_extension_values() {
assert_eq!(escape_cef_value("key=value"), "key\\=value");
assert_eq!(escape_cef_value("line\nnew"), "line\\nnew");
}
#[test]
fn cef_severity_mapping() {
assert_eq!(cef_severity(ThreatSeverity::Info), 1);
assert_eq!(cef_severity(ThreatSeverity::High), 8);
assert_eq!(cef_severity(ThreatSeverity::Critical), 10);
}
}

View file

@ -0,0 +1,11 @@
//! SIEM/SOAR integration format exporters.
//!
//! Converts verified IoCs to industry-standard SIEM ingestion formats:
//!
//! - `splunk` — Splunk HTTP Event Collector (HEC) JSON format
//! - `qradar` — IBM QRadar LEEF (Log Event Extended Format)
//! - `cef` — ArcSight Common Event Format (CEF)
pub mod cef;
pub mod qradar;
pub mod splunk;

View file

@ -0,0 +1,142 @@
/// IBM QRadar LEEF (Log Event Extended Format) exporter.
///
/// Converts verified IoCs to LEEF 2.0 format for ingestion by
/// IBM QRadar SIEM via log source or Syslog.
///
/// LEEF format: `LEEF:2.0|Vendor|Product|Version|EventID\tkey=value\tkey=value`
///
/// Reference: <https://www.ibm.com/docs/en/dsm?topic=leef-overview>
use common::hivemind::{self, IoCType, ThreatSeverity};
use crate::store::{ip_to_string, unix_to_iso8601, VerifiedIoC};
/// Convert a verified IoC to LEEF 2.0 format string.
///
/// The returned string is a single LEEF event line suitable for
/// Syslog forwarding or file-based ingestion.
pub fn ioc_to_leef(vioc: &VerifiedIoC) -> String {
let ioc = &vioc.ioc;
let ioc_type = IoCType::from_u8(ioc.ioc_type);
let severity = ThreatSeverity::from_u8(ioc.severity);
let event_id = leef_event_id(ioc_type);
let sev = leef_severity(severity);
// LEEF header
let header = format!(
"LEEF:2.0|{}|{}|{}|{}",
hivemind::SIEM_VENDOR,
hivemind::SIEM_PRODUCT,
hivemind::SIEM_VERSION,
event_id,
);
// LEEF attributes (tab-delimited)
let src_ip = ip_to_string(ioc.ip);
let timestamp = unix_to_iso8601(vioc.verified_at);
let desc = escape_leef_value(&ioc.description);
let mut attrs = format!(
"sev={sev}\tsrc={src_ip}\tdevTime={timestamp}\tcat={event_id}\t\
msg={desc}\tconfirmations={}\tstix_id={}",
ioc.confirmations,
escape_leef_value(&vioc.stix_id),
);
if let Some(ref ja4) = ioc.ja4 {
attrs.push_str(&format!("\tja4={}", escape_leef_value(ja4)));
}
if let Some(entropy) = ioc.entropy_score {
attrs.push_str(&format!("\tentropy_score={entropy}"));
}
format!("{header}\t{attrs}")
}
/// Convert a batch of verified IoCs to newline-delimited LEEF.
pub fn batch_to_leef(iocs: &[VerifiedIoC]) -> String {
iocs.iter()
.map(ioc_to_leef)
.collect::<Vec<_>>()
.join("\n")
}
/// Map IoC type to LEEF event ID.
fn leef_event_id(t: IoCType) -> &'static str {
match t {
IoCType::MaliciousIp => "MaliciousIP",
IoCType::Ja4Fingerprint => "JA4Fingerprint",
IoCType::EntropyAnomaly => "EntropyAnomaly",
IoCType::DnsTunnel => "DNSTunnel",
IoCType::BehavioralPattern => "BehavioralPattern",
}
}
/// Map threat severity to LEEF numeric severity (1-10).
fn leef_severity(s: ThreatSeverity) -> u8 {
match s {
ThreatSeverity::Info => 1,
ThreatSeverity::Low => 3,
ThreatSeverity::Medium => 5,
ThreatSeverity::High => 7,
ThreatSeverity::Critical => 10,
}
}
/// Escape special characters in LEEF attribute values.
///
/// LEEF uses tab as delimiter — tabs and newlines must be escaped.
fn escape_leef_value(s: &str) -> String {
s.replace('\t', "\\t")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
#[cfg(test)]
mod tests {
use super::*;
use common::hivemind::IoC;
fn sample_vioc() -> VerifiedIoC {
VerifiedIoC {
ioc: IoC {
ioc_type: 0,
severity: 3,
ip: 0xC0A80001,
ja4: Some("t13d1516h2_abc".to_string()),
entropy_score: Some(7500),
description: "Malicious IP detected".to_string(),
first_seen: 1700000000,
confirmations: 3,
zkp_proof: Vec::new(),
},
verified_at: 1700001000,
stix_id: "indicator--aabb".to_string(),
}
}
#[test]
fn leef_header_format() {
let vioc = sample_vioc();
let leef = ioc_to_leef(&vioc);
assert!(leef.starts_with("LEEF:2.0|Blackwall|HiveMind|1.0|MaliciousIP"));
assert!(leef.contains("sev=7"));
assert!(leef.contains("src=192.168.0.1"));
assert!(leef.contains("ja4=t13d1516h2_abc"));
assert!(leef.contains("entropy_score=7500"));
}
#[test]
fn leef_escapes_special_chars() {
let escaped = escape_leef_value("test\ttab\nnewline");
assert_eq!(escaped, "test\\ttab\\nnewline");
}
#[test]
fn leef_severity_mapping() {
assert_eq!(leef_severity(ThreatSeverity::Info), 1);
assert_eq!(leef_severity(ThreatSeverity::Critical), 10);
}
}

View file

@ -0,0 +1,159 @@
/// Splunk HTTP Event Collector (HEC) format exporter.
///
/// Converts verified IoCs to Splunk HEC JSON format suitable for
/// direct ingestion via the Splunk HEC endpoint.
///
/// Format reference: <https://docs.splunk.com/Documentation/Splunk/latest/Data/FormateventsforHTTPEventCollector>
use common::hivemind::{self, IoCType, ThreatSeverity};
use serde::Serialize;
use crate::store::{ip_to_string, VerifiedIoC};
/// Splunk HEC event wrapper.
#[derive(Clone, Debug, Serialize)]
pub struct SplunkEvent {
/// Unix timestamp of the event.
pub time: u64,
/// Splunk source identifier.
pub source: &'static str,
/// Splunk sourcetype for indexing.
pub sourcetype: &'static str,
/// Target Splunk index.
pub index: &'static str,
/// Event payload.
pub event: SplunkEventData,
}
/// Inner event data for Splunk HEC.
#[derive(Clone, Debug, Serialize)]
pub struct SplunkEventData {
/// IoC type as human-readable string.
pub ioc_type: &'static str,
/// Severity as human-readable string.
pub severity: &'static str,
/// Numeric severity (0-4).
pub severity_id: u8,
/// Source IP in dotted notation (if applicable).
pub src_ip: String,
/// JA4 fingerprint (if applicable).
#[serde(skip_serializing_if = "Option::is_none")]
pub ja4: Option<String>,
/// Byte diversity score (if applicable).
#[serde(skip_serializing_if = "Option::is_none")]
pub entropy_score: Option<u32>,
/// Human-readable description.
pub description: String,
/// Number of independent confirmations.
pub confirmations: u32,
/// Unix timestamp when first observed.
pub first_seen: u64,
/// Unix timestamp when consensus was reached.
pub verified_at: u64,
/// STIX identifier for cross-referencing.
pub stix_id: String,
}
/// Convert a verified IoC to a Splunk HEC event.
pub fn ioc_to_splunk(vioc: &VerifiedIoC) -> SplunkEvent {
let ioc = &vioc.ioc;
let ioc_type = IoCType::from_u8(ioc.ioc_type);
let severity = ThreatSeverity::from_u8(ioc.severity);
SplunkEvent {
time: vioc.verified_at,
source: "hivemind",
sourcetype: hivemind::SPLUNK_SOURCETYPE,
index: "threat_intel",
event: SplunkEventData {
ioc_type: ioc_type_label(ioc_type),
severity: severity_label(severity),
severity_id: ioc.severity,
src_ip: ip_to_string(ioc.ip),
ja4: ioc.ja4.clone(),
entropy_score: ioc.entropy_score,
description: ioc.description.clone(),
confirmations: ioc.confirmations,
first_seen: ioc.first_seen,
verified_at: vioc.verified_at,
stix_id: vioc.stix_id.clone(),
},
}
}
/// Convert a batch of verified IoCs to Splunk HEC events.
pub fn batch_to_splunk(iocs: &[VerifiedIoC]) -> Vec<SplunkEvent> {
iocs.iter().map(ioc_to_splunk).collect()
}
/// Human-readable IoC type label.
fn ioc_type_label(t: IoCType) -> &'static str {
match t {
IoCType::MaliciousIp => "malicious_ip",
IoCType::Ja4Fingerprint => "ja4_fingerprint",
IoCType::EntropyAnomaly => "entropy_anomaly",
IoCType::DnsTunnel => "dns_tunnel",
IoCType::BehavioralPattern => "behavioral_pattern",
}
}
/// Human-readable severity label.
fn severity_label(s: ThreatSeverity) -> &'static str {
match s {
ThreatSeverity::Info => "info",
ThreatSeverity::Low => "low",
ThreatSeverity::Medium => "medium",
ThreatSeverity::High => "high",
ThreatSeverity::Critical => "critical",
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::hivemind::IoC;
fn sample_vioc() -> VerifiedIoC {
VerifiedIoC {
ioc: IoC {
ioc_type: 0,
severity: 3,
ip: 0xC0A80001,
ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_string()),
entropy_score: Some(7500),
description: "Malicious IP".to_string(),
first_seen: 1700000000,
confirmations: 3,
zkp_proof: Vec::new(),
},
verified_at: 1700001000,
stix_id: "indicator--aabbccdd-1122-3344-5566-778899aabbcc".to_string(),
}
}
#[test]
fn splunk_event_fields() {
let vioc = sample_vioc();
let event = ioc_to_splunk(&vioc);
assert_eq!(event.time, 1700001000);
assert_eq!(event.source, "hivemind");
assert_eq!(event.sourcetype, hivemind::SPLUNK_SOURCETYPE);
assert_eq!(event.event.ioc_type, "malicious_ip");
assert_eq!(event.event.severity, "high");
assert_eq!(event.event.src_ip, "192.168.0.1");
assert_eq!(event.event.confirmations, 3);
}
#[test]
fn splunk_severity_mapping() {
assert_eq!(severity_label(ThreatSeverity::Info), "info");
assert_eq!(severity_label(ThreatSeverity::Critical), "critical");
}
#[test]
fn splunk_batch() {
let iocs = vec![sample_vioc(), sample_vioc()];
let batch = batch_to_splunk(&iocs);
assert_eq!(batch.len(), 2);
}
}

20
hivemind-api/src/lib.rs Executable file
View file

@ -0,0 +1,20 @@
//! HiveMind Enterprise Threat Feed API.
//!
//! Provides REST, STIX/TAXII 2.1, and SIEM integration endpoints
//! for consuming verified threat intelligence from the HiveMind mesh.
//!
//! # Modules
//!
//! - `store` — In-memory verified IoC storage with time-windowed queries
//! - `stix` — STIX 2.1 types and IoC→STIX indicator conversion
//! - `feed` — Query parameter parsing, filtering, and pagination
//! - `integrations` — SIEM format exporters (Splunk HEC, QRadar LEEF, CEF)
//! - `licensing` — API key management and tier-based access control
//! - `server` — HTTP server with request routing
pub mod feed;
pub mod integrations;
pub mod licensing;
pub mod server;
pub mod stix;
pub mod store;

197
hivemind-api/src/licensing.rs Executable file
View file

@ -0,0 +1,197 @@
/// API key management and tier-based access control.
///
/// Manages API keys for the Enterprise Threat Feed. Each key is
/// associated with an `ApiTier` that determines access to SIEM
/// formats, page size limits, and STIX/TAXII endpoints.
///
/// API keys are stored as SHA256 hashes — the raw key is never
/// persisted after initial generation.
use common::hivemind::{self, ApiTier};
use ring::digest;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::{info, warn};
/// Thread-safe handle to the license manager.
pub type SharedLicenseManager = Arc<RwLock<LicenseManager>>;
/// Manages API keys and their associated tiers.
pub struct LicenseManager {
/// Map from SHA256(api_key) hex → tier.
keys: HashMap<String, ApiTier>,
}
impl Default for LicenseManager {
fn default() -> Self {
Self::new()
}
}
impl LicenseManager {
/// Create a new empty license manager.
pub fn new() -> Self {
Self {
keys: HashMap::new(),
}
}
/// Create a shared (thread-safe) handle to a new license manager.
pub fn shared() -> SharedLicenseManager {
Arc::new(RwLock::new(Self::new()))
}
/// Register an API key with the given tier.
///
/// The key is immediately hashed — only the hash is stored.
/// Returns the key hash for logging purposes.
pub fn register_key(&mut self, raw_key: &str, tier: ApiTier) -> String {
let hash = hash_api_key(raw_key);
self.keys.insert(hash.clone(), tier);
info!(
key_hash = &hash[..16],
?tier,
total_keys = self.keys.len(),
"API key registered"
);
hash
}
/// Validate an API key and return its tier.
///
/// Returns `None` if the key is not registered.
pub fn validate(&self, raw_key: &str) -> Option<ApiTier> {
let hash = hash_api_key(raw_key);
let result = self.keys.get(&hash).copied();
if result.is_none() {
warn!(
key_hash = &hash[..16],
"API key validation failed — unknown key"
);
}
result
}
/// Revoke an API key.
///
/// Returns `true` if the key existed and was removed.
pub fn revoke_key(&mut self, raw_key: &str) -> bool {
let hash = hash_api_key(raw_key);
let removed = self.keys.remove(&hash).is_some();
if removed {
info!(key_hash = &hash[..16], "API key revoked");
}
removed
}
/// Total number of registered API keys.
pub fn key_count(&self) -> usize {
self.keys.len()
}
}
/// Compute SHA256 hash of an API key, returned as lowercase hex.
fn hash_api_key(raw_key: &str) -> String {
let hash = digest::digest(&digest::SHA256, raw_key.as_bytes());
hash.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// Extract an API key from an HTTP Authorization header value.
///
/// Expects format: `Bearer <api-key>`
/// Returns `None` if the header is missing or malformed.
pub fn extract_bearer_token(auth_header: Option<&str>) -> Option<&str> {
let header = auth_header?;
let stripped = header.strip_prefix("Bearer ")?;
let token = stripped.trim();
if token.is_empty() {
return None;
}
// SECURITY: Enforce maximum token length to prevent DoS via huge headers
if token.len() > hivemind::API_KEY_LENGTH * 2 + 16 {
return None;
}
Some(token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_and_validate() {
let mut mgr = LicenseManager::new();
mgr.register_key("test-key-123", ApiTier::Enterprise);
let tier = mgr.validate("test-key-123");
assert_eq!(tier, Some(ApiTier::Enterprise));
}
#[test]
fn invalid_key_returns_none() {
let mgr = LicenseManager::new();
assert_eq!(mgr.validate("nonexistent"), None);
}
#[test]
fn revoke_key() {
let mut mgr = LicenseManager::new();
mgr.register_key("revoke-me", ApiTier::Free);
assert!(mgr.validate("revoke-me").is_some());
assert!(mgr.revoke_key("revoke-me"));
assert!(mgr.validate("revoke-me").is_none());
}
#[test]
fn different_tiers() {
let mut mgr = LicenseManager::new();
mgr.register_key("free-key", ApiTier::Free);
mgr.register_key("enterprise-key", ApiTier::Enterprise);
mgr.register_key("ns-key", ApiTier::NationalSecurity);
assert_eq!(mgr.validate("free-key"), Some(ApiTier::Free));
assert_eq!(mgr.validate("enterprise-key"), Some(ApiTier::Enterprise));
assert_eq!(
mgr.validate("ns-key"),
Some(ApiTier::NationalSecurity)
);
assert_eq!(mgr.key_count(), 3);
}
#[test]
fn key_hash_is_deterministic() {
let h1 = hash_api_key("same-key");
let h2 = hash_api_key("same-key");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64); // SHA256 hex = 64 chars
}
#[test]
fn extract_bearer_valid() {
assert_eq!(
extract_bearer_token(Some("Bearer my-api-key")),
Some("my-api-key")
);
}
#[test]
fn extract_bearer_missing() {
assert_eq!(extract_bearer_token(None), None);
assert_eq!(extract_bearer_token(Some("")), None);
assert_eq!(extract_bearer_token(Some("Basic abc")), None);
assert_eq!(extract_bearer_token(Some("Bearer ")), None);
}
#[test]
fn tier_access_control() {
assert!(!ApiTier::Free.can_access_siem());
assert!(ApiTier::Enterprise.can_access_siem());
assert!(ApiTier::NationalSecurity.can_access_taxii());
assert_eq!(ApiTier::Free.max_page_size(), 50);
assert_eq!(ApiTier::Enterprise.max_page_size(), 1000);
}
}

71
hivemind-api/src/main.rs Executable file
View file

@ -0,0 +1,71 @@
/// HiveMind Enterprise Threat Feed API — entry point.
///
/// Starts the HTTP server with configured address, initializes the
/// in-memory IoC store and license manager, and serves threat feed
/// endpoints.
///
/// # Usage
///
/// ```sh
/// hivemind-api
/// ```
///
/// # Configuration
///
/// Currently reads from defaults. Production configuration will
/// come from a TOML file via the `common` crate config layer.
use std::net::SocketAddr;
use common::hivemind;
use tracing::info;
use tracing_subscriber::EnvFilter;
use hivemind_api::licensing::LicenseManager;
use hivemind_api::server::{self, HivemindCounters};
use hivemind_api::store::ThreatFeedStore;
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info")),
)
.compact()
.init();
info!(
version = hivemind::SIEM_VERSION,
"Starting HiveMind Enterprise Threat Feed API"
);
// Initialize shared state
let store = ThreatFeedStore::shared();
let licensing = LicenseManager::shared();
let counters = std::sync::Arc::new(HivemindCounters::default());
// Load bootstrap API keys from environment (if any)
// SECURITY: Keys from env vars, never hardcoded
if let Ok(key) = std::env::var("HIVEMIND_API_KEY_ENTERPRISE") {
licensing
.write()
.expect("licensing lock not poisoned")
.register_key(&key, hivemind::ApiTier::Enterprise);
}
if let Ok(key) = std::env::var("HIVEMIND_API_KEY_NS") {
licensing
.write()
.expect("licensing lock not poisoned")
.register_key(&key, hivemind::ApiTier::NationalSecurity);
}
let addr = SocketAddr::from((
hivemind::API_DEFAULT_ADDR
.parse::<std::net::Ipv4Addr>()
.expect("valid default bind address"),
hivemind::API_DEFAULT_PORT,
));
server::run(addr, store, licensing, counters).await
}

489
hivemind-api/src/server.rs Executable file
View file

@ -0,0 +1,489 @@
/// HTTP server with request routing and response formatting.
///
/// Implements a hyper 1.x HTTP server with manual path-based routing.
/// All endpoints require API key authentication via `Authorization: Bearer <key>`
/// header, except for the TAXII discovery endpoint.
///
/// # Endpoints
///
/// | Path | Description | Tier |
/// |------|-------------|------|
/// | `GET /taxii2/` | TAXII 2.1 API root discovery | Any |
/// | `GET /taxii2/collections/` | List TAXII collections | Enterprise+ |
/// | `GET /taxii2/collections/{id}/objects/` | STIX objects | Enterprise+ |
/// | `GET /api/v1/feed` | JSON feed of verified IoCs | Any |
/// | `GET /api/v1/feed/stix` | STIX 2.1 bundle | Enterprise+ |
/// | `GET /api/v1/feed/splunk` | Splunk HEC format | Enterprise+ |
/// | `GET /api/v1/feed/qradar` | QRadar LEEF format | Enterprise+ |
/// | `GET /api/v1/feed/cef` | CEF format | Enterprise+ |
/// | `GET /api/v1/stats` | Feed statistics | Any |
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use common::hivemind::{self, ApiTier};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tracing::{error, info, warn};
use crate::feed;
use crate::integrations::{cef, qradar, splunk};
use crate::licensing::{self, SharedLicenseManager};
use crate::stix;
use crate::store::SharedStore;
/// Live counters pushed by blackwall, hivemind, and enterprise daemons (optional)
/// via `POST /push`. Each daemon pushes only its own fields.
#[derive(Default)]
pub struct HivemindCounters {
// eBPF/XDP counters (pushed by blackwall daemon)
pub packets_total: AtomicU64,
pub packets_passed: AtomicU64,
pub packets_dropped: AtomicU64,
pub anomalies_sent: AtomicU64,
// P2P mesh counters (pushed by hivemind daemon)
pub peer_count: AtomicU64,
pub iocs_shared_p2p: AtomicU64,
pub avg_reputation_x100: AtomicU64,
pub messages_total: AtomicU64,
// A2A counters (pushed by enterprise module when active)
pub a2a_jwts_verified: AtomicU64,
pub a2a_violations: AtomicU64,
pub a2a_injections: AtomicU64,
}
pub type SharedCounters = Arc<HivemindCounters>;
/// Delta payload for `POST /push`.
///
/// All fields are optional so each daemon can push only its own counters
/// without zeroing out counters owned by other daemons.
#[derive(serde::Deserialize)]
struct CounterDelta {
// eBPF counters (from blackwall)
packets_total: Option<u64>,
packets_passed: Option<u64>,
packets_dropped: Option<u64>,
anomalies_sent: Option<u64>,
// P2P counters (from hivemind)
peer_count: Option<u64>,
iocs_shared_p2p: Option<u64>,
avg_reputation_x100: Option<u64>,
messages_total: Option<u64>,
// A2A counters (from enterprise module)
a2a_jwts_verified: Option<u64>,
a2a_violations: Option<u64>,
a2a_injections: Option<u64>,
}
/// Start the HTTP server and listen for connections.
///
/// This function runs forever (until the process is terminated).
/// Each incoming connection spawns a new task for HTTP/1.1 handling.
pub async fn run(
addr: SocketAddr,
store: SharedStore,
licensing: SharedLicenseManager,
counters: SharedCounters,
) -> anyhow::Result<()> {
let listener = TcpListener::bind(addr).await?;
info!(%addr, "Enterprise Threat Feed API listening");
loop {
let (stream, peer) = listener.accept().await?;
let io = TokioIo::new(stream);
let store = store.clone();
let licensing = licensing.clone();
let counters = counters.clone();
tokio::task::spawn(async move {
let service = service_fn(move |req| {
let store = store.clone();
let licensing = licensing.clone();
let counters = counters.clone();
async move { handle_request(req, store, licensing, counters, peer).await }
});
if let Err(e) = http1::Builder::new()
.serve_connection(io, service)
.await
{
error!(peer = %peer, error = %e, "HTTP connection error");
}
});
}
}
/// Route an HTTP request to the appropriate handler.
async fn handle_request(
req: Request<hyper::body::Incoming>,
store: SharedStore,
licensing: SharedLicenseManager,
counters: SharedCounters,
peer: SocketAddr,
) -> Result<Response<Full<Bytes>>, Infallible> {
let method = req.method().clone();
let path = req.uri().path().to_string();
let query = req.uri().query().map(|q| q.to_string());
// Extract API key
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok());
let token = licensing::extract_bearer_token(auth_header);
let had_token = token.is_some();
// Validate API key
let tier = match token {
Some(key) => {
let mgr = licensing
.read()
.expect("licensing lock not poisoned");
mgr.validate(key)
}
None => None,
};
info!(
%peer,
%method,
path = %path,
authenticated = tier.is_some(),
"Request received"
);
// Route based on method + path
let response = match (&method, path.as_str()) {
// TAXII 2.1 endpoints
(&Method::GET, "/taxii2/") => handle_taxii_discovery(),
(&Method::GET, "/taxii2/collections/") => {
require_taxii(tier, had_token, handle_taxii_collections)
}
(&Method::GET, p) if is_taxii_objects_path(p) => {
require_taxii(tier, had_token, || {
let store = store
.read()
.expect("store lock not poisoned");
let max_page = tier.map_or(50, |t| t.max_page_size());
let params = feed::parse_query_params(query.as_deref(), max_page);
let result = store.query(&params);
let bundle = stix::build_bundle(&result.items);
json_response(StatusCode::OK, hivemind::STIX_CONTENT_TYPE, &bundle)
})
}
// Custom REST endpoints
(&Method::GET, "/api/v1/feed") => {
let effective_tier = tier.unwrap_or(ApiTier::Free);
let store = store
.read()
.expect("store lock not poisoned");
let params = feed::parse_query_params(
query.as_deref(),
effective_tier.max_page_size(),
);
let result = store.query(&params);
json_response(StatusCode::OK, "application/json", &result)
}
(&Method::GET, "/api/v1/feed/stix") => {
require_taxii(tier, had_token, || {
let store = store
.read()
.expect("store lock not poisoned");
let max_page = tier.map_or(50, |t| t.max_page_size());
let params = feed::parse_query_params(query.as_deref(), max_page);
let result = store.query(&params);
let bundle = stix::build_bundle(&result.items);
json_response(StatusCode::OK, hivemind::STIX_CONTENT_TYPE, &bundle)
})
}
(&Method::GET, "/api/v1/feed/splunk") => {
require_siem(tier, had_token, || {
let store = store
.read()
.expect("store lock not poisoned");
let max_page = tier.map_or(50, |t| t.max_page_size());
let params = feed::parse_query_params(query.as_deref(), max_page);
let result = store.query(&params);
let events = splunk::batch_to_splunk(&result.items);
json_response(StatusCode::OK, "application/json", &events)
})
}
(&Method::GET, "/api/v1/feed/qradar") => {
require_siem(tier, had_token, || {
let store = store
.read()
.expect("store lock not poisoned");
let max_page = tier.map_or(50, |t| t.max_page_size());
let params = feed::parse_query_params(query.as_deref(), max_page);
let result = store.query(&params);
text_response(
StatusCode::OK,
"text/plain",
&qradar::batch_to_leef(&result.items),
)
})
}
(&Method::GET, "/api/v1/feed/cef") => {
require_siem(tier, had_token, || {
let store = store
.read()
.expect("store lock not poisoned");
let max_page = tier.map_or(50, |t| t.max_page_size());
let params = feed::parse_query_params(query.as_deref(), max_page);
let result = store.query(&params);
text_response(
StatusCode::OK,
"text/plain",
&cef::batch_to_cef(&result.items),
)
})
}
(&Method::GET, "/api/v1/stats") => {
let store = store
.read()
.expect("store lock not poisoned");
let stats = feed::compute_stats(&store);
json_response(StatusCode::OK, "application/json", &stats)
}
// Dashboard mesh stats endpoint (no auth required)
(&Method::GET, "/stats") => {
let store = store
.read()
.expect("store lock not poisoned");
let mesh = feed::compute_mesh_stats(&store, &counters);
json_response(StatusCode::OK, "application/json", &mesh)
}
// Internal metrics push from blackwall daemon (localhost only)
(&Method::POST, "/push") => {
// SECURITY: only accept from loopback
if !peer.ip().is_loopback() {
warn!(%peer, "rejected /push from non-loopback");
return Ok(error_response(StatusCode::FORBIDDEN, "Forbidden"));
}
let body_bytes = match req.collect().await {
Ok(b) => b.to_bytes(),
Err(_) => return Ok(error_response(StatusCode::BAD_REQUEST, "bad body")),
};
match serde_json::from_slice::<CounterDelta>(&body_bytes) {
Ok(delta) => {
// eBPF counters (from blackwall)
if let Some(v) = delta.packets_total {
counters.packets_total.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.packets_passed {
counters.packets_passed.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.packets_dropped {
counters.packets_dropped.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.anomalies_sent {
counters.anomalies_sent.store(v, Ordering::Relaxed);
}
// P2P counters (from hivemind)
if let Some(v) = delta.peer_count {
counters.peer_count.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.iocs_shared_p2p {
counters.iocs_shared_p2p.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.avg_reputation_x100 {
counters.avg_reputation_x100.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.messages_total {
counters.messages_total.store(v, Ordering::Relaxed);
}
// A2A counters (from enterprise module)
if let Some(v) = delta.a2a_jwts_verified {
counters.a2a_jwts_verified.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.a2a_violations {
counters.a2a_violations.store(v, Ordering::Relaxed);
}
if let Some(v) = delta.a2a_injections {
counters.a2a_injections.store(v, Ordering::Relaxed);
}
json_response(StatusCode::OK, "application/json", &serde_json::json!({"ok": true}))
}
Err(e) => {
warn!(%e, "failed to parse /push payload");
error_response(StatusCode::BAD_REQUEST, "invalid JSON")
}
}
}
_ => {
warn!(%method, path = %path, "Unknown endpoint");
error_response(StatusCode::NOT_FOUND, "Not found")
}
};
Ok(response)
}
// --- TAXII endpoint handlers ---
/// Handle TAXII 2.1 API root discovery (no auth required).
fn handle_taxii_discovery() -> Response<Full<Bytes>> {
let discovery = stix::discovery_response();
json_response(StatusCode::OK, hivemind::TAXII_CONTENT_TYPE, &discovery)
}
/// Handle TAXII 2.1 collection listing.
fn handle_taxii_collections() -> Response<Full<Bytes>> {
let collections = vec![stix::default_collection()];
let wrapper = serde_json::json!({ "collections": collections });
json_response(StatusCode::OK, hivemind::TAXII_CONTENT_TYPE, &wrapper)
}
// --- Access control helpers ---
/// Require Enterprise+ tier for TAXII endpoints.
fn require_taxii<F>(tier: Option<ApiTier>, had_token: bool, f: F) -> Response<Full<Bytes>>
where
F: FnOnce() -> Response<Full<Bytes>>,
{
match tier {
Some(t) if t.can_access_taxii() => f(),
Some(_) => error_response(
StatusCode::FORBIDDEN,
"TAXII endpoints require Enterprise or NationalSecurity tier",
),
None if had_token => error_response(
StatusCode::UNAUTHORIZED,
"Invalid API key",
),
None => error_response(
StatusCode::UNAUTHORIZED,
"Authorization header with Bearer token required",
),
}
}
/// Require Enterprise+ tier for SIEM integration endpoints.
fn require_siem<F>(tier: Option<ApiTier>, had_token: bool, f: F) -> Response<Full<Bytes>>
where
F: FnOnce() -> Response<Full<Bytes>>,
{
match tier {
Some(t) if t.can_access_siem() => f(),
Some(_) => error_response(
StatusCode::FORBIDDEN,
"SIEM integration endpoints require Enterprise or NationalSecurity tier",
),
None if had_token => error_response(
StatusCode::UNAUTHORIZED,
"Invalid API key",
),
None => error_response(
StatusCode::UNAUTHORIZED,
"Authorization header with Bearer token required",
),
}
}
// --- Path matching ---
/// Check if a path matches the TAXII collection objects pattern.
///
/// Pattern: `/taxii2/collections/<id>/objects/`
fn is_taxii_objects_path(path: &str) -> bool {
let Some(rest) = path.strip_prefix("/taxii2/collections/") else {
return false;
};
rest.ends_with("/objects/") && rest.len() > "/objects/".len()
}
// --- Response builders ---
/// Build a JSON response with the given status and content type.
fn json_response<T: serde::Serialize>(
status: StatusCode,
content_type: &str,
body: &T,
) -> Response<Full<Bytes>> {
let json = serde_json::to_string(body).unwrap_or_else(|e| {
format!("{{\"error\":\"serialization failed: {e}\"}}")
});
Response::builder()
.status(status)
.header("content-type", content_type)
.header("x-hivemind-version", hivemind::SIEM_VERSION)
.body(Full::new(Bytes::from(json)))
.expect("building response with valid parameters")
}
/// Build a plain-text response.
fn text_response(
status: StatusCode,
content_type: &str,
body: &str,
) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.header("content-type", content_type)
.header("x-hivemind-version", hivemind::SIEM_VERSION)
.body(Full::new(Bytes::from(body.to_owned())))
.expect("building response with valid parameters")
}
/// Build a JSON error response.
fn error_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
let body = serde_json::json!({
"error": message,
"status": status.as_u16(),
});
json_response(status, "application/json", &body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn taxii_objects_path_matching() {
assert!(is_taxii_objects_path(
"/taxii2/collections/hivemind-threat-feed-v1/objects/"
));
assert!(!is_taxii_objects_path("/taxii2/collections/"));
assert!(!is_taxii_objects_path("/taxii2/collections/objects/"));
assert!(!is_taxii_objects_path("/api/v1/feed"));
}
#[test]
fn error_response_format() {
let resp = error_response(StatusCode::UNAUTHORIZED, "test error");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let ct = resp.headers().get("content-type").expect("has content-type");
assert_eq!(ct, "application/json");
}
#[test]
fn json_response_has_version_header() {
let resp = json_response(StatusCode::OK, "application/json", &"hello");
let ver = resp
.headers()
.get("x-hivemind-version")
.expect("has version");
assert_eq!(ver, hivemind::SIEM_VERSION);
}
}

328
hivemind-api/src/stix.rs Executable file
View file

@ -0,0 +1,328 @@
/// STIX 2.1 types and IoC-to-STIX conversion.
///
/// Implements core STIX Structured Threat Information Expression objects
/// for the Enterprise Threat Feed API. Converts HiveMind IoCs to
/// STIX Indicator SDOs within STIX Bundles.
///
/// Reference: <https://docs.oasis-open.org/cti/stix/v2.1/os/stix-v2.1-os.html>
use common::hivemind::{self, IoCType, ThreatSeverity};
use serde::Serialize;
use crate::store::{ip_to_string, unix_to_iso8601, VerifiedIoC};
/// STIX 2.1 Bundle — top-level container for STIX objects.
#[derive(Clone, Debug, Serialize)]
pub struct StixBundle {
/// Always "bundle".
#[serde(rename = "type")]
pub object_type: &'static str,
/// Deterministic bundle ID.
pub id: String,
/// List of STIX objects.
pub objects: Vec<StixIndicator>,
}
/// STIX 2.1 Indicator SDO — represents an IoC observation.
#[derive(Clone, Debug, Serialize)]
pub struct StixIndicator {
/// Always "indicator".
#[serde(rename = "type")]
pub object_type: &'static str,
/// STIX spec version.
pub spec_version: &'static str,
/// Deterministic STIX ID (from store).
pub id: String,
/// ISO 8601 creation timestamp.
pub created: String,
/// ISO 8601 modification timestamp.
pub modified: String,
/// Human-readable indicator name.
pub name: String,
/// STIX pattern expression.
pub pattern: String,
/// Pattern language (always "stix").
pub pattern_type: &'static str,
/// When this indicator becomes valid.
pub valid_from: String,
/// Confidence score (0-100).
pub confidence: u8,
/// Indicator type labels.
pub indicator_types: Vec<&'static str>,
/// Descriptive labels.
pub labels: Vec<String>,
}
/// TAXII 2.1 Collection resource.
#[derive(Clone, Debug, Serialize)]
pub struct TaxiiCollection {
/// Collection identifier.
pub id: String,
/// Human-readable title.
pub title: String,
/// Description.
pub description: String,
/// Whether this collection can be read.
pub can_read: bool,
/// Whether this collection can be written to.
pub can_write: bool,
/// Supported media types.
pub media_types: Vec<&'static str>,
}
/// TAXII 2.1 API Root discovery response.
#[derive(Clone, Debug, Serialize)]
pub struct TaxiiDiscovery {
/// API root title.
pub title: String,
/// Description.
pub description: String,
/// Supported TAXII versions.
pub versions: Vec<&'static str>,
/// Maximum content length.
pub max_content_length: usize,
}
/// Convert a verified IoC to a STIX 2.1 Indicator.
pub fn ioc_to_indicator(vioc: &VerifiedIoC) -> StixIndicator {
let ioc = &vioc.ioc;
let ioc_type = IoCType::from_u8(ioc.ioc_type);
let severity = ThreatSeverity::from_u8(ioc.severity);
let name = build_indicator_name(ioc_type, ioc);
let pattern = build_stix_pattern(ioc_type, ioc);
let confidence = severity_to_confidence(severity);
let indicator_types = ioc_type_to_stix_types(ioc_type);
let created = unix_to_iso8601(vioc.verified_at);
let valid_from = unix_to_iso8601(ioc.first_seen);
StixIndicator {
object_type: "indicator",
spec_version: hivemind::STIX_SPEC_VERSION,
id: vioc.stix_id.clone(),
created: created.clone(),
modified: created,
name,
pattern,
pattern_type: "stix",
valid_from,
confidence,
indicator_types,
labels: vec![format!("severity:{}", ioc.severity)],
}
}
/// Build a STIX bundle from a list of verified IoCs.
pub fn build_bundle(iocs: &[VerifiedIoC]) -> StixBundle {
let objects: Vec<StixIndicator> = iocs.iter().map(ioc_to_indicator).collect();
// Bundle ID: deterministic from object count + first ID
let bundle_suffix = if let Some(first) = objects.first() {
first.id.chars().skip(12).take(36).collect::<String>()
} else {
"00000000-0000-0000-0000-000000000000".to_string()
};
StixBundle {
object_type: "bundle",
id: format!("bundle--{bundle_suffix}"),
objects,
}
}
/// Build the default TAXII collection descriptor.
pub fn default_collection() -> TaxiiCollection {
TaxiiCollection {
id: hivemind::TAXII_COLLECTION_ID.to_string(),
title: hivemind::TAXII_COLLECTION_TITLE.to_string(),
description: "Consensus-verified threat indicators from the HiveMind P2P mesh. \
Each IoC has been cross-validated by at least 3 independent peers."
.to_string(),
can_read: true,
can_write: false,
media_types: vec![hivemind::STIX_CONTENT_TYPE],
}
}
/// Build the TAXII API root discovery response.
pub fn discovery_response() -> TaxiiDiscovery {
TaxiiDiscovery {
title: "HiveMind Threat Feed".to_string(),
description: "TAXII 2.1 API for the HiveMind decentralized threat intelligence mesh."
.to_string(),
versions: vec!["taxii-2.1"],
max_content_length: hivemind::MAX_MESSAGE_SIZE,
}
}
/// Build a human-readable indicator name from IoC fields.
fn build_indicator_name(ioc_type: IoCType, ioc: &common::hivemind::IoC) -> String {
match ioc_type {
IoCType::MaliciousIp => {
format!("Malicious IP {}", ip_to_string(ioc.ip))
}
IoCType::Ja4Fingerprint => {
let ja4 = ioc.ja4.as_deref().unwrap_or("unknown");
format!("Malicious JA4 fingerprint {ja4}")
}
IoCType::EntropyAnomaly => {
let score = ioc.entropy_score.unwrap_or(0);
format!("High-entropy anomaly (score={score}) from {}", ip_to_string(ioc.ip))
}
IoCType::DnsTunnel => {
format!("DNS tunneling from {}", ip_to_string(ioc.ip))
}
IoCType::BehavioralPattern => {
format!("Behavioral anomaly from {}", ip_to_string(ioc.ip))
}
}
}
/// Build a STIX pattern expression from an IoC.
///
/// STIX patterns follow the STIX Patterning language:
/// `[<object-type>:<property> = '<value>']`
fn build_stix_pattern(ioc_type: IoCType, ioc: &common::hivemind::IoC) -> String {
match ioc_type {
IoCType::MaliciousIp => {
format!("[ipv4-addr:value = '{}']", ip_to_string(ioc.ip))
}
IoCType::Ja4Fingerprint => {
let ja4 = ioc.ja4.as_deref().unwrap_or("unknown");
format!("[network-traffic:extensions.'tls-ext'.ja4 = '{ja4}']")
}
IoCType::EntropyAnomaly => {
format!(
"[network-traffic:src_ref.type = 'ipv4-addr' AND \
network-traffic:src_ref.value = '{}']",
ip_to_string(ioc.ip)
)
}
IoCType::DnsTunnel => {
format!(
"[domain-name:resolves_to_refs[*].value = '{}']",
ip_to_string(ioc.ip)
)
}
IoCType::BehavioralPattern => {
format!(
"[network-traffic:src_ref.type = 'ipv4-addr' AND \
network-traffic:src_ref.value = '{}']",
ip_to_string(ioc.ip)
)
}
}
}
/// Map threat severity to STIX confidence score (0-100).
fn severity_to_confidence(severity: ThreatSeverity) -> u8 {
match severity {
ThreatSeverity::Info => 20,
ThreatSeverity::Low => 40,
ThreatSeverity::Medium => 60,
ThreatSeverity::High => 80,
ThreatSeverity::Critical => 95,
}
}
/// Map IoC type to STIX indicator type labels.
fn ioc_type_to_stix_types(ioc_type: IoCType) -> Vec<&'static str> {
match ioc_type {
IoCType::MaliciousIp => vec!["malicious-activity", "anomalous-activity"],
IoCType::Ja4Fingerprint => vec!["malicious-activity"],
IoCType::EntropyAnomaly => vec!["anomalous-activity"],
IoCType::DnsTunnel => vec!["malicious-activity", "anomalous-activity"],
IoCType::BehavioralPattern => vec!["anomalous-activity"],
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::hivemind::IoC;
fn sample_ioc() -> IoC {
IoC {
ioc_type: 0, // MaliciousIp
severity: 3, // High
ip: 0xC0A80001,
ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_string()),
entropy_score: Some(7500),
description: "Test malicious IP".to_string(),
first_seen: 1700000000,
confirmations: 3,
zkp_proof: Vec::new(),
}
}
fn sample_verified() -> VerifiedIoC {
VerifiedIoC {
stix_id: "indicator--aabbccdd-1122-3344-5566-778899aabbcc".to_string(),
verified_at: 1700001000,
ioc: sample_ioc(),
}
}
#[test]
fn ioc_converts_to_indicator() {
let vioc = sample_verified();
let indicator = ioc_to_indicator(&vioc);
assert_eq!(indicator.object_type, "indicator");
assert_eq!(indicator.spec_version, "2.1");
assert_eq!(indicator.id, vioc.stix_id);
assert_eq!(indicator.pattern_type, "stix");
assert_eq!(indicator.confidence, 80); // High severity
assert!(indicator.name.contains("192.168.0.1"));
assert!(indicator.pattern.contains("192.168.0.1"));
}
#[test]
fn bundle_creation() {
let viocs = vec![sample_verified()];
let bundle = build_bundle(&viocs);
assert_eq!(bundle.object_type, "bundle");
assert!(bundle.id.starts_with("bundle--"));
assert_eq!(bundle.objects.len(), 1);
}
#[test]
fn empty_bundle() {
let bundle = build_bundle(&[]);
assert_eq!(bundle.objects.len(), 0);
assert!(bundle.id.starts_with("bundle--"));
}
#[test]
fn stix_patterns_by_type() {
let ioc = sample_ioc();
// MaliciousIp
let pattern = build_stix_pattern(IoCType::MaliciousIp, &ioc);
assert!(pattern.starts_with("[ipv4-addr:value"));
// Ja4Fingerprint
let mut ja4_ioc = ioc.clone();
ja4_ioc.ioc_type = 1;
let pattern = build_stix_pattern(IoCType::Ja4Fingerprint, &ja4_ioc);
assert!(pattern.contains("tls-ext"));
// DnsTunnel
let pattern = build_stix_pattern(IoCType::DnsTunnel, &ioc);
assert!(pattern.contains("domain-name"));
}
#[test]
fn taxii_discovery() {
let disc = discovery_response();
assert!(disc.versions.contains(&"taxii-2.1"));
}
#[test]
fn taxii_collection() {
let coll = default_collection();
assert_eq!(coll.id, hivemind::TAXII_COLLECTION_ID);
assert!(coll.can_read);
assert!(!coll.can_write);
}
}

351
hivemind-api/src/store.rs Executable file
View file

@ -0,0 +1,351 @@
/// In-memory store for consensus-verified IoCs.
///
/// The `ThreatFeedStore` holds all IoCs that reached cross-validation
/// consensus in the HiveMind mesh. It supports time-windowed queries,
/// filtering by severity and type, and pagination for API responses.
use common::hivemind::{self, IoC};
use ring::digest;
use std::sync::{Arc, RwLock};
use tracing::info;
/// A consensus-verified IoC with feed metadata.
#[derive(Clone, Debug, serde::Serialize)]
pub struct VerifiedIoC {
/// The verified IoC data.
pub ioc: IoC,
/// Unix timestamp when consensus was reached.
pub verified_at: u64,
/// Pre-computed deterministic STIX identifier.
pub stix_id: String,
}
/// Thread-safe handle to the IoC store.
pub type SharedStore = Arc<RwLock<ThreatFeedStore>>;
/// In-memory storage for verified IoCs, sorted by verification time.
pub struct ThreatFeedStore {
/// Verified IoCs, ordered by `verified_at` ascending.
iocs: Vec<VerifiedIoC>,
}
impl Default for ThreatFeedStore {
fn default() -> Self {
Self::new()
}
}
impl ThreatFeedStore {
/// Create a new empty store.
pub fn new() -> Self {
Self { iocs: Vec::new() }
}
/// Create a shared (thread-safe) handle to a new store.
pub fn shared() -> SharedStore {
Arc::new(RwLock::new(Self::new()))
}
/// Insert a verified IoC into the store.
///
/// Computes the deterministic STIX ID from the IoC fields and
/// inserts in sorted order by verification timestamp.
pub fn insert(&mut self, ioc: IoC, verified_at: u64) {
let stix_id = compute_stix_id(&ioc);
let entry = VerifiedIoC {
ioc,
verified_at,
stix_id,
};
// Insert in sorted order (most entries append at the end)
let pos = self
.iocs
.partition_point(|e| e.verified_at <= verified_at);
self.iocs.insert(pos, entry);
info!(
total = self.iocs.len(),
verified_at,
"IoC added to threat feed store"
);
}
/// Query IoCs with filtering and pagination.
pub fn query(&self, params: &QueryParams) -> QueryResult {
let filtered: Vec<&VerifiedIoC> = self
.iocs
.iter()
.filter(|e| {
if let Some(since) = params.since {
if e.verified_at < since {
return false;
}
}
if let Some(min_sev) = params.min_severity {
if e.ioc.severity < min_sev {
return false;
}
}
if let Some(ioc_type) = params.ioc_type {
if e.ioc.ioc_type != ioc_type {
return false;
}
}
true
})
.collect();
let total = filtered.len();
let offset = params.offset.min(total);
let limit = params.limit.min(hivemind::API_MAX_PAGE_SIZE);
let end = (offset + limit).min(total);
let items: Vec<VerifiedIoC> = filtered[offset..end]
.iter()
.map(|e| (*e).clone())
.collect();
QueryResult {
items,
total,
offset,
limit,
}
}
/// Total number of verified IoCs in the store.
pub fn len(&self) -> usize {
self.iocs.len()
}
/// Whether the store is empty.
pub fn is_empty(&self) -> bool {
self.iocs.is_empty()
}
/// Get all IoCs (for stats/internal use). Returns a slice reference.
pub fn all(&self) -> &[VerifiedIoC] {
&self.iocs
}
}
/// Parameters for querying the threat feed store.
#[derive(Clone, Debug, Default)]
pub struct QueryParams {
/// Only return IoCs verified after this Unix timestamp.
pub since: Option<u64>,
/// Minimum severity level (0-4).
pub min_severity: Option<u8>,
/// Filter by IoC type.
pub ioc_type: Option<u8>,
/// Maximum items to return.
pub limit: usize,
/// Offset for pagination.
pub offset: usize,
}
impl QueryParams {
/// Create default query with standard page size.
pub fn new() -> Self {
Self {
since: None,
min_severity: None,
ioc_type: None,
limit: hivemind::API_DEFAULT_PAGE_SIZE,
offset: 0,
}
}
}
/// Result of a store query with pagination metadata.
#[derive(Clone, Debug, serde::Serialize)]
pub struct QueryResult {
/// Matching IoCs for the current page.
pub items: Vec<VerifiedIoC>,
/// Total matching IoCs (before pagination).
pub total: usize,
/// Current offset.
pub offset: usize,
/// Page size used.
pub limit: usize,
}
/// Compute a deterministic STIX identifier from IoC fields.
///
/// Format: `indicator--<uuid>` where UUID is derived from
/// SHA256(ioc_type || ip || ja4 || first_seen).
fn compute_stix_id(ioc: &IoC) -> String {
let mut data = Vec::with_capacity(64);
data.push(ioc.ioc_type);
data.extend_from_slice(&ioc.ip.to_be_bytes());
if let Some(ref ja4) = ioc.ja4 {
data.extend_from_slice(ja4.as_bytes());
}
data.extend_from_slice(&ioc.first_seen.to_be_bytes());
let hash = digest::digest(&digest::SHA256, &data);
let h = hash.as_ref();
// Format as UUID-like identifier (deterministic, reproducible)
format!(
"indicator--{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}\
-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8], h[9], h[10],
h[11], h[12], h[13], h[14], h[15],
)
}
/// Convert an IPv4 u32 to dotted-decimal string.
pub fn ip_to_string(ip: u32) -> String {
let a = (ip >> 24) & 0xFF;
let b = (ip >> 16) & 0xFF;
let c = (ip >> 8) & 0xFF;
let d = ip & 0xFF;
format!("{a}.{b}.{c}.{d}")
}
/// Convert a Unix timestamp to ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ).
///
/// Uses Howard Hinnant's civil_from_days algorithm for calendar conversion.
pub fn unix_to_iso8601(ts: u64) -> String {
let time_of_day = ts % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let days = (ts / 86400) as i64;
// Howard Hinnant's civil_from_days algorithm
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ioc(ip: u32, severity: u8, ioc_type: u8) -> IoC {
IoC {
ioc_type,
severity,
ip,
ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_string()),
entropy_score: Some(7500),
description: format!("Test IoC ip={ip}"),
first_seen: 1700000000,
confirmations: 3,
zkp_proof: Vec::new(),
}
}
#[test]
fn insert_and_query_all() {
let mut store = ThreatFeedStore::new();
store.insert(make_ioc(1, 3, 0), 1000);
store.insert(make_ioc(2, 2, 1), 2000);
store.insert(make_ioc(3, 4, 0), 3000);
let result = store.query(&QueryParams::new());
assert_eq!(result.total, 3);
assert_eq!(result.items.len(), 3);
}
#[test]
fn query_by_severity() {
let mut store = ThreatFeedStore::new();
store.insert(make_ioc(1, 1, 0), 1000);
store.insert(make_ioc(2, 3, 0), 2000);
store.insert(make_ioc(3, 4, 0), 3000);
let params = QueryParams {
min_severity: Some(3),
..QueryParams::new()
};
let result = store.query(&params);
assert_eq!(result.total, 2);
assert!(result.items.iter().all(|i| i.ioc.severity >= 3));
}
#[test]
fn query_by_type() {
let mut store = ThreatFeedStore::new();
store.insert(make_ioc(1, 3, 0), 1000);
store.insert(make_ioc(2, 3, 1), 2000);
store.insert(make_ioc(3, 3, 0), 3000);
let params = QueryParams {
ioc_type: Some(1),
..QueryParams::new()
};
let result = store.query(&params);
assert_eq!(result.total, 1);
assert_eq!(result.items[0].ioc.ioc_type, 1);
}
#[test]
fn query_since_timestamp() {
let mut store = ThreatFeedStore::new();
store.insert(make_ioc(1, 3, 0), 1000);
store.insert(make_ioc(2, 3, 0), 2000);
store.insert(make_ioc(3, 3, 0), 3000);
let params = QueryParams {
since: Some(2000),
..QueryParams::new()
};
let result = store.query(&params);
assert_eq!(result.total, 2);
}
#[test]
fn pagination() {
let mut store = ThreatFeedStore::new();
for i in 0..10 {
store.insert(make_ioc(i, 3, 0), 1000 + u64::from(i));
}
let params = QueryParams {
limit: 3,
offset: 2,
..QueryParams::new()
};
let result = store.query(&params);
assert_eq!(result.total, 10);
assert_eq!(result.items.len(), 3);
assert_eq!(result.offset, 2);
}
#[test]
fn stix_id_deterministic() {
let ioc = make_ioc(0xC0A80001, 3, 0);
let id1 = compute_stix_id(&ioc);
let id2 = compute_stix_id(&ioc);
assert_eq!(id1, id2);
assert!(id1.starts_with("indicator--"));
}
#[test]
fn ip_conversion() {
assert_eq!(ip_to_string(0xC0A80001), "192.168.0.1");
assert_eq!(ip_to_string(0x0A000001), "10.0.0.1");
assert_eq!(ip_to_string(0), "0.0.0.0");
}
#[test]
fn timestamp_conversion() {
// 2023-11-14T22:13:20Z
assert_eq!(unix_to_iso8601(1700000000), "2023-11-14T22:13:20Z");
// Unix epoch
assert_eq!(unix_to_iso8601(0), "1970-01-01T00:00:00Z");
}
}

451
hivemind-api/tests/load_test.rs Executable file
View file

@ -0,0 +1,451 @@
//! Load Test & Licensing Lockdown — Stress simulation for HiveMind Enterprise API.
//!
//! Spawns a real hyper server, seeds it with IoCs, then hammers it with
//! concurrent clients measuring response latency and verifying tier-based
//! access control enforcement.
use common::hivemind::{ApiTier, IoC};
use hivemind_api::licensing::LicenseManager;
use hivemind_api::server::{self, HivemindCounters};
use hivemind_api::store::ThreatFeedStore;
use std::net::SocketAddr;
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Make a raw HTTP/1.1 GET request and return (status_code, body).
async fn http_get(addr: SocketAddr, path: &str, bearer: Option<&str>) -> (u16, String) {
let mut stream = TcpStream::connect(addr)
.await
.expect("TCP connect failed");
let mut request = format!(
"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n"
);
if let Some(token) = bearer {
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
request.push_str("\r\n");
stream
.write_all(request.as_bytes())
.await
.expect("write request failed");
let mut buf = Vec::with_capacity(8192);
stream
.read_to_end(&mut buf)
.await
.expect("read response failed");
let raw = String::from_utf8_lossy(&buf);
// Parse status code from "HTTP/1.1 NNN ..."
let status = raw
.get(9..12)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
// Split at the blank line separating headers from body
let body = raw
.find("\r\n\r\n")
.map(|i| raw[i + 4..].to_string())
.unwrap_or_default();
(status, body)
}
/// Seed the store with `count` synthetic IoCs at 1-second intervals.
fn seed_store(store: &mut ThreatFeedStore, count: usize) {
let base_time = 1_700_000_000u64;
for i in 0..count {
let ioc = IoC {
ioc_type: (i % 5) as u8,
severity: ((i % 5) as u8).min(4),
ip: 0xC6120000 + i as u32, // 198.18.x.x range
ja4: if i % 3 == 0 {
Some("t13d1516h2_8daaf6152771_e5627efa2ab1".into())
} else {
None
},
entropy_score: Some(5000 + (i as u32 * 100)),
description: format!("Synthetic threat indicator #{i}"),
first_seen: base_time + i as u64,
confirmations: 3,
zkp_proof: Vec::new(),
};
store.insert(ioc, base_time + i as u64 + 60);
}
}
/// Find a free TCP port by binding to :0 and reading the assigned port.
async fn free_port() -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind :0 failed");
listener
.local_addr()
.expect("local_addr failed")
.port()
}
// ===========================================================================
// Scenario A — API Hammering: 100 concurrent clients
// ===========================================================================
#[tokio::test(flavor = "current_thread")]
async fn scenario_a_api_hammering() {
// Setup
let store = ThreatFeedStore::shared();
let licensing = LicenseManager::shared();
// Seed 50 IoCs
{
let mut s = store.write().expect("lock");
seed_store(&mut s, 50);
}
// Register an Enterprise-tier key
let api_key = "test-enterprise-key-12345678";
{
let mut lm = licensing.write().expect("lock");
lm.register_key(api_key, ApiTier::Enterprise);
}
// Start server on a random port
let port = free_port().await;
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let server_store = store.clone();
let server_lic = licensing.clone();
let counters = std::sync::Arc::new(HivemindCounters::default());
let server_handle = tokio::task::spawn(async move {
let _ = server::run(addr, server_store, server_lic, counters).await;
});
// Give the server a moment to bind — try connecting in a quick retry loop
let mut connected = false;
for _ in 0..50 {
if TcpStream::connect(addr).await.is_ok() {
connected = true;
break;
}
tokio::task::yield_now().await;
}
assert!(connected, "Server did not start within retry window");
// Define the endpoints to hit
let endpoints = [
"/api/v1/feed",
"/api/v1/feed/stix",
"/api/v1/feed/splunk",
"/api/v1/stats",
];
// Spawn 100 concurrent client tasks
let client_count = 100;
let mut handles = Vec::with_capacity(client_count);
let start = Instant::now();
for i in 0..client_count {
let endpoint = endpoints[i % endpoints.len()];
let key = api_key.to_string();
handles.push(tokio::task::spawn(async move {
let t = Instant::now();
let (status, body) = http_get(addr, endpoint, Some(&key)).await;
let latency = t.elapsed();
(i, status, body.len(), latency)
}));
}
// Collect results
let mut total_latency = std::time::Duration::ZERO;
let mut max_latency = std::time::Duration::ZERO;
let mut error_count = 0;
for handle in handles {
let (idx, status, body_len, latency) = handle.await.expect("task panicked");
if status != 200 {
error_count += 1;
eprintln!(
"[HAMMER] Client {idx}: HTTP {status} (body {body_len}B) in {latency:.2?}"
);
}
total_latency += latency;
if latency > max_latency {
max_latency = latency;
}
}
let total_elapsed = start.elapsed();
let avg_latency = total_latency / client_count as u32;
eprintln!(
"[HAMMER] {client_count} clients completed in {total_elapsed:.2?}"
);
eprintln!(
"[HAMMER] Avg latency: {avg_latency:.2?}, Max: {max_latency:.2?}, Errors: {error_count}"
);
assert_eq!(
error_count, 0,
"All authenticated requests should succeed (HTTP 200)"
);
// Abort the server
server_handle.abort();
}
// ===========================================================================
// Scenario B — Licensing Lockdown: tier-based access denial
// ===========================================================================
#[tokio::test(flavor = "current_thread")]
async fn scenario_b_licensing_lockdown() {
let store = ThreatFeedStore::shared();
let licensing = LicenseManager::shared();
// Seed 10 IoCs
{
let mut s = store.write().expect("lock");
seed_store(&mut s, 10);
}
// Register keys at each tier
let free_key = "free-tier-key-aaaa";
let enterprise_key = "enterprise-tier-key-bbbb";
let ns_key = "national-security-key-cccc";
{
let mut lm = licensing.write().expect("lock");
lm.register_key(free_key, ApiTier::Free);
lm.register_key(enterprise_key, ApiTier::Enterprise);
lm.register_key(ns_key, ApiTier::NationalSecurity);
}
let port = free_port().await;
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let server_store = store.clone();
let server_lic = licensing.clone();
let counters = std::sync::Arc::new(HivemindCounters::default());
let server_handle = tokio::task::spawn(async move {
let _ = server::run(addr, server_store, server_lic, counters).await;
});
// Wait for server
for _ in 0..50 {
if TcpStream::connect(addr).await.is_ok() {
break;
}
tokio::task::yield_now().await;
}
// --- Free tier: /api/v1/feed should work ---
let (status, _) = http_get(addr, "/api/v1/feed", Some(free_key)).await;
assert_eq!(status, 200, "Free tier should access /api/v1/feed");
// --- Free tier: /api/v1/stats should work ---
let (status, _) = http_get(addr, "/api/v1/stats", Some(free_key)).await;
assert_eq!(status, 200, "Free tier should access /api/v1/stats");
// --- Free tier: SIEM endpoints BLOCKED ---
let siem_paths = [
"/api/v1/feed/splunk",
"/api/v1/feed/qradar",
"/api/v1/feed/cef",
];
for path in &siem_paths {
let (status, _) = http_get(addr, path, Some(free_key)).await;
assert_eq!(
status, 403,
"Free tier should be FORBIDDEN from {path}"
);
}
// --- Free tier: STIX/TAXII endpoints BLOCKED ---
let taxii_paths = [
"/api/v1/feed/stix",
"/taxii2/collections/",
];
for path in &taxii_paths {
let (status, _) = http_get(addr, path, Some(free_key)).await;
assert_eq!(
status, 403,
"Free tier should be FORBIDDEN from {path}"
);
}
// --- No auth: should get 401 Unauthorized ---
let (status, _) = http_get(addr, "/api/v1/feed/splunk", None).await;
assert_eq!(
status, 401,
"No auth header should yield 401 Unauthorized"
);
// --- Invalid key: should get denied ---
let (status, _) = http_get(addr, "/api/v1/feed", Some("totally-bogus-key")).await;
// /api/v1/feed allows unauthenticated access via effective_tier=Free fallback,
// but with an invalid key, the server resolves tier to None and falls through
// to Free default for /api/v1/feed
assert!(
status == 200 || status == 401,
"/api/v1/feed with invalid key: got {status}"
);
// --- Enterprise tier: SIEM endpoints ALLOWED ---
for path in &siem_paths {
let (status, body) = http_get(addr, path, Some(enterprise_key)).await;
assert_eq!(
status, 200,
"Enterprise tier should access {path}, got {status}"
);
assert!(!body.is_empty(), "{path} response body should not be empty");
}
// --- Enterprise tier: TAXII endpoints ALLOWED ---
for path in &taxii_paths {
let (status, _) = http_get(addr, path, Some(enterprise_key)).await;
assert_eq!(
status, 200,
"Enterprise tier should access {path}, got {status}"
);
}
// --- NationalSecurity tier: everything ALLOWED ---
let all_paths = [
"/api/v1/feed",
"/api/v1/feed/stix",
"/api/v1/feed/splunk",
"/api/v1/feed/qradar",
"/api/v1/feed/cef",
"/api/v1/stats",
"/taxii2/",
"/taxii2/collections/",
];
for path in &all_paths {
let (status, _) = http_get(addr, path, Some(ns_key)).await;
assert_eq!(
status, 200,
"NationalSecurity tier should access {path}, got {status}"
);
}
// --- Unknown endpoint: 404 ---
let (status, _) = http_get(addr, "/api/v1/nonexistent", Some(enterprise_key)).await;
assert_eq!(status, 404, "Unknown endpoint should yield 404");
eprintln!("[LOCKDOWN] All tier-based access control assertions passed");
server_handle.abort();
}
// ===========================================================================
// Scenario C — Feed Content Integrity: verify response payloads
// ===========================================================================
#[tokio::test(flavor = "current_thread")]
async fn scenario_c_feed_content_integrity() {
let store = ThreatFeedStore::shared();
let licensing = LicenseManager::shared();
// Seed 5 IoCs
{
let mut s = store.write().expect("lock");
seed_store(&mut s, 5);
}
let api_key = "integrity-test-key";
{
let mut lm = licensing.write().expect("lock");
lm.register_key(api_key, ApiTier::Enterprise);
}
let port = free_port().await;
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let ss = store.clone();
let sl = licensing.clone();
let counters = std::sync::Arc::new(HivemindCounters::default());
let server_handle = tokio::task::spawn(async move {
let _ = server::run(addr, ss, sl, counters).await;
});
for _ in 0..50 {
if TcpStream::connect(addr).await.is_ok() {
break;
}
tokio::task::yield_now().await;
}
// --- JSON feed ---
let (status, body) = http_get(addr, "/api/v1/feed", Some(api_key)).await;
assert_eq!(status, 200);
let parsed: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("Invalid JSON in /api/v1/feed response: {e}"));
assert!(
parsed.get("items").is_some(),
"Feed response should contain 'items' field"
);
assert!(
parsed.get("total").is_some(),
"Feed response should contain 'total' field"
);
// --- STIX bundle ---
let (status, body) = http_get(addr, "/api/v1/feed/stix", Some(api_key)).await;
assert_eq!(status, 200);
let stix: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("Invalid STIX JSON: {e}"));
assert_eq!(
stix.get("type").and_then(|t| t.as_str()),
Some("bundle"),
"STIX response should be a bundle"
);
// --- Splunk HEC ---
let (status, body) = http_get(addr, "/api/v1/feed/splunk", Some(api_key)).await;
assert_eq!(status, 200);
let splunk: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("Invalid Splunk JSON: {e}"));
assert!(splunk.is_array(), "Splunk response should be a JSON array");
// --- QRadar LEEF (plain text) ---
let (status, body) = http_get(addr, "/api/v1/feed/qradar", Some(api_key)).await;
assert_eq!(status, 200);
assert!(
body.contains("LEEF:"),
"QRadar response should contain LEEF headers"
);
// --- CEF (plain text) ---
let (status, body) = http_get(addr, "/api/v1/feed/cef", Some(api_key)).await;
assert_eq!(status, 200);
assert!(
body.contains("CEF:"),
"CEF response should contain CEF headers"
);
// --- Stats ---
let (status, body) = http_get(addr, "/api/v1/stats", Some(api_key)).await;
assert_eq!(status, 200);
let stats: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("Invalid stats JSON: {e}"));
let total = stats
.get("total_iocs")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert_eq!(total, 5, "Stats should report 5 total IoCs");
// --- TAXII discovery (no auth needed, but we send one) ---
let (status, body) = http_get(addr, "/taxii2/", Some(api_key)).await;
assert_eq!(status, 200);
let taxii: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("Invalid TAXII JSON: {e}"));
assert!(
taxii.get("title").is_some(),
"TAXII discovery should contain title"
);
eprintln!("[INTEGRITY] All 7 endpoints return well-formed responses");
server_handle.abort();
}

17
hivemind-dashboard/Cargo.toml Executable file
View file

@ -0,0 +1,17 @@
[package]
name = "hivemind-dashboard"
version = "0.1.0"
edition = "2021"
description = "Live TUI dashboard for HiveMind mesh monitoring"
[dependencies]
common = { path = "../common", default-features = false, features = ["user"] }
tokio = { workspace = true }
hyper = { workspace = true }
http-body-util = { workspace = true }
hyper-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

345
hivemind-dashboard/src/app.rs Executable file
View file

@ -0,0 +1,345 @@
//! ANSI terminal dashboard renderer.
//!
//! Renders a live-updating dashboard using only ANSI escape codes.
//! No external TUI crates — pure `\x1B[` sequences.
use std::io::{self, Write};
use crate::collector::MeshStats;
/// ANSI color codes.
const GREEN: &str = "\x1B[32m";
const YELLOW: &str = "\x1B[33m";
const RED: &str = "\x1B[31m";
const CYAN: &str = "\x1B[36m";
const BOLD: &str = "\x1B[1m";
const DIM: &str = "\x1B[2m";
const RESET: &str = "\x1B[0m";
/// Unicode box-drawing characters.
const TL: char = '┌';
const TR: char = '┐';
const BL: char = '└';
const BR: char = '┘';
const HZ: char = '─';
const VT: char = '│';
/// Terminal dashboard state.
pub struct Dashboard {
stats: MeshStats,
frame: u64,
}
impl Dashboard {
pub fn new() -> Self {
Self {
stats: MeshStats::default(),
frame: 0,
}
}
/// Update dashboard with fresh stats.
pub fn update(&mut self, stats: MeshStats) {
self.stats = stats;
self.frame += 1;
}
/// Render the full dashboard to the given writer.
pub fn render<W: Write>(&self, w: &mut W) -> io::Result<()> {
// Move cursor to top-left, clear screen
write!(w, "\x1B[H\x1B[2J")?;
self.render_header(w)?;
writeln!(w)?;
self.render_mesh_panel(w)?;
writeln!(w)?;
self.render_threat_panel(w)?;
writeln!(w)?;
self.render_network_fw_panel(w)?;
writeln!(w)?;
self.render_a2a_panel(w)?;
writeln!(w)?;
self.render_crypto_panel(w)?;
writeln!(w)?;
self.render_footer(w)?;
w.flush()
}
fn render_header<W: Write>(&self, w: &mut W) -> io::Result<()> {
let status = if self.stats.connected {
format!("{GREEN}● CONNECTED{RESET}")
} else {
format!("{RED}○ DISCONNECTED{RESET}")
};
writeln!(
w,
" {BOLD}{CYAN}╔══════════════════════════════════════════════════╗{RESET}"
)?;
writeln!(
w,
" {BOLD}{CYAN}║{RESET} {BOLD}BLACKWALL HIVEMIND{RESET} {DIM}v2.0{RESET} \
{status} {DIM}frame #{}{RESET} {BOLD}{CYAN}{RESET}",
self.frame,
)?;
writeln!(
w,
" {BOLD}{CYAN}╚══════════════════════════════════════════════════╝{RESET}"
)
}
fn render_mesh_panel<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.draw_box_top(w, " P2P Mesh ", 48)?;
self.draw_kv(w, "Peers", &self.stats.peer_count.to_string(), 48)?;
self.draw_kv(
w,
"DHT Records",
&self.stats.dht_records.to_string(),
48,
)?;
self.draw_kv(
w,
"GossipSub Topics",
&self.stats.gossip_topics.to_string(),
48,
)?;
self.draw_kv(
w,
"Messages/s",
&format!("{:.1}", self.stats.messages_per_sec),
48,
)?;
self.draw_box_bottom(w, 48)
}
fn render_threat_panel<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.draw_box_top(w, " Threat Intel ", 48)?;
self.draw_kv(
w,
"IoCs Shared",
&self.stats.iocs_shared.to_string(),
48,
)?;
self.draw_kv(
w,
"IoCs Received",
&self.stats.iocs_received.to_string(),
48,
)?;
let reputation_color = if self.stats.avg_reputation > 80.0 {
GREEN
} else if self.stats.avg_reputation > 50.0 {
YELLOW
} else {
RED
};
self.draw_kv_colored(
w,
"Avg Reputation",
&format!("{:.1}", self.stats.avg_reputation),
reputation_color,
48,
)?;
self.draw_box_bottom(w, 48)
}
fn render_a2a_panel<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.draw_box_top(w, " A2A Firewall ", 48)?;
self.draw_kv(
w,
"JWTs Verified",
&self.stats.a2a_jwts_verified.to_string(),
48,
)?;
self.draw_kv(
w,
"Violations Blocked",
&self.stats.a2a_violations.to_string(),
48,
)?;
self.draw_kv(
w,
"Injections Detected",
&self.stats.a2a_injections.to_string(),
48,
)?;
self.draw_box_bottom(w, 48)
}
fn render_network_fw_panel<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.draw_box_top(w, " Network Firewall ", 48)?;
self.draw_kv(
w,
"Packets Total",
&self.stats.packets_total.to_string(),
48,
)?;
self.draw_kv(
w,
"Packets Passed",
&self.stats.packets_passed.to_string(),
48,
)?;
let dropped_color = if self.stats.packets_dropped > 0 {
RED
} else {
GREEN
};
self.draw_kv_colored(
w,
"Packets Dropped",
&self.stats.packets_dropped.to_string(),
dropped_color,
48,
)?;
let anomaly_color = if self.stats.anomalies_sent > 0 {
YELLOW
} else {
GREEN
};
self.draw_kv_colored(
w,
"Anomalies",
&self.stats.anomalies_sent.to_string(),
anomaly_color,
48,
)?;
self.draw_box_bottom(w, 48)
}
fn render_crypto_panel<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.draw_box_top(w, " Cryptography ", 48)?;
self.draw_kv(
w,
"ZKP Proofs Generated",
&self.stats.zkp_proofs_generated.to_string(),
48,
)?;
self.draw_kv(
w,
"ZKP Proofs Verified",
&self.stats.zkp_proofs_verified.to_string(),
48,
)?;
let fhe_status = if self.stats.fhe_encrypted {
format!("{GREEN}AES-256-GCM{RESET}")
} else {
format!("{YELLOW}STUB{RESET}")
};
self.draw_kv(w, "FHE Mode", &fhe_status, 48)?;
self.draw_box_bottom(w, 48)
}
fn render_footer<W: Write>(&self, w: &mut W) -> io::Result<()> {
writeln!(
w,
" {DIM}Press Ctrl+C to exit | Refresh: 1s | \
API: hivemind-api{RESET}"
)
}
// ── Box drawing helpers ─────────────────────────────────────
fn draw_box_top<W: Write>(
&self,
w: &mut W,
title: &str,
width: usize,
) -> io::Result<()> {
let inner = width - 2 - title.len();
write!(w, " {TL}{HZ}")?;
write!(w, "{BOLD}{title}{RESET}")?;
for _ in 0..inner {
write!(w, "{HZ}")?;
}
writeln!(w, "{TR}")
}
fn draw_box_bottom<W: Write>(&self, w: &mut W, width: usize) -> io::Result<()> {
write!(w, " {BL}")?;
for _ in 0..width - 2 {
write!(w, "{HZ}")?;
}
writeln!(w, "{BR}")
}
fn draw_kv<W: Write>(
&self,
w: &mut W,
key: &str,
value: &str,
width: usize,
) -> io::Result<()> {
let padding = width - 6 - key.len() - value.len();
let pad = if padding > 0 { padding } else { 1 };
write!(w, " {VT} {key}")?;
for _ in 0..pad {
write!(w, " ")?;
}
writeln!(w, "{BOLD}{value}{RESET} {VT}")
}
fn draw_kv_colored<W: Write>(
&self,
w: &mut W,
key: &str,
value: &str,
color: &str,
width: usize,
) -> io::Result<()> {
let padding = width - 6 - key.len() - value.len();
let pad = if padding > 0 { padding } else { 1 };
write!(w, " {VT} {key}")?;
for _ in 0..pad {
write!(w, " ")?;
}
writeln!(w, "{color}{BOLD}{value}{RESET} {VT}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_default_dashboard() {
let dash = Dashboard::new();
let mut buf = Vec::new();
dash.render(&mut buf).expect("render");
let output = String::from_utf8(buf).expect("utf8");
assert!(output.contains("BLACKWALL HIVEMIND"));
assert!(output.contains("P2P Mesh"));
assert!(output.contains("Threat Intel"));
assert!(output.contains("Network Firewall"));
assert!(output.contains("A2A Firewall"));
assert!(output.contains("Cryptography"));
}
#[test]
fn update_increments_frame() {
let mut dash = Dashboard::new();
assert_eq!(dash.frame, 0);
dash.update(MeshStats::default());
assert_eq!(dash.frame, 1);
dash.update(MeshStats::default());
assert_eq!(dash.frame, 2);
}
#[test]
fn connected_shows_green() {
let mut dash = Dashboard::new();
dash.update(MeshStats {
connected: true,
..Default::default()
});
let mut buf = Vec::new();
dash.render(&mut buf).expect("render");
let output = String::from_utf8(buf).expect("utf8");
assert!(output.contains("CONNECTED"));
}
}

View file

@ -0,0 +1,150 @@
//! Stats collector — polls hivemind-api for mesh statistics.
use serde::Deserialize;
use tracing::warn;
/// Collected mesh statistics for dashboard rendering.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MeshStats {
/// Whether we're connected to the API.
#[serde(default)]
pub connected: bool,
// P2P Mesh
#[serde(default)]
pub peer_count: u64,
#[serde(default)]
pub dht_records: u64,
#[serde(default)]
pub gossip_topics: u64,
#[serde(default)]
pub messages_per_sec: f64,
// Threat Intel
#[serde(default)]
pub iocs_shared: u64,
#[serde(default)]
pub iocs_received: u64,
#[serde(default)]
pub avg_reputation: f64,
// Network Firewall (XDP/eBPF)
#[serde(default)]
pub packets_total: u64,
#[serde(default)]
pub packets_passed: u64,
#[serde(default)]
pub packets_dropped: u64,
#[serde(default)]
pub anomalies_sent: u64,
// A2A Firewall (separate counters)
#[serde(default)]
pub a2a_jwts_verified: u64,
#[serde(default)]
pub a2a_violations: u64,
#[serde(default)]
pub a2a_injections: u64,
// Cryptography
#[serde(default)]
pub zkp_proofs_generated: u64,
#[serde(default)]
pub zkp_proofs_verified: u64,
#[serde(default)]
pub fhe_encrypted: bool,
}
/// HTTP collector that polls the hivemind-api stats endpoint.
pub struct Collector {
url: String,
}
impl Collector {
/// Create a new collector targeting the given base URL.
pub fn new(base_url: &str) -> Self {
Self {
url: format!("{}/stats", base_url.trim_end_matches('/')),
}
}
/// Fetch latest stats from the API.
///
/// Returns default stats with `connected = false` on any error.
pub async fn fetch(&self) -> MeshStats {
match self.fetch_inner().await {
Ok(mut stats) => {
stats.connected = true;
stats
}
Err(e) => {
warn!(url = %self.url, error = %e, "failed to fetch mesh stats");
MeshStats {
connected: false,
..Default::default()
}
}
}
}
async fn fetch_inner(&self) -> Result<MeshStats, Box<dyn std::error::Error>> {
// Use a simple TCP connection + manual HTTP/1.1 request
// to avoid pulling in heavy HTTP client deps.
let url: hyper::Uri = self.url.parse()?;
let host = url.host().unwrap_or("127.0.0.1");
let port = url.port_u16().unwrap_or(9100);
let path = url.path();
let stream = tokio::net::TcpStream::connect(format!("{host}:{port}")).await?;
let io = hyper_util::rt::TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
tokio::spawn(async move {
if let Err(e) = conn.await {
warn!(error = %e, "HTTP connection error");
}
});
let req = hyper::Request::builder()
.uri(path)
.header("Host", host)
.body(http_body_util::Empty::<hyper::body::Bytes>::new())?;
let resp = sender.send_request(req).await?;
use http_body_util::BodyExt;
let body = resp.into_body().collect().await?.to_bytes();
let stats: MeshStats = serde_json::from_slice(&body)?;
Ok(stats)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_stats_disconnected() {
let stats = MeshStats::default();
assert!(!stats.connected);
assert_eq!(stats.peer_count, 0);
}
#[test]
fn deserialize_partial_json() {
let json = r#"{"peer_count": 42, "fhe_encrypted": true}"#;
let stats: MeshStats = serde_json::from_str(json).expect("parse");
assert_eq!(stats.peer_count, 42);
assert!(stats.fhe_encrypted);
assert_eq!(stats.a2a_jwts_verified, 0); // default
}
#[test]
fn collector_url_construction() {
let c1 = Collector::new("http://localhost:9100");
assert_eq!(c1.url, "http://localhost:9100/stats");
let c2 = Collector::new("http://localhost:9100/");
assert_eq!(c2.url, "http://localhost:9100/stats");
}
}

76
hivemind-dashboard/src/main.rs Executable file
View file

@ -0,0 +1,76 @@
//! HiveMind TUI Dashboard — live mesh monitoring via ANSI terminal.
//!
//! Zero external TUI deps — pure ANSI escape codes + tokio.
//! Polls the hivemind-api HTTP endpoint for stats and renders
//! a live-updating dashboard in the terminal.
use std::io::{self, Write};
use std::time::Duration;
use tokio::signal;
use tracing_subscriber::EnvFilter;
mod app;
mod collector;
use app::Dashboard;
use collector::Collector;
/// Default refresh interval in milliseconds.
const DEFAULT_REFRESH_MS: u64 = 1000;
/// Default API endpoint (hivemind-api default port).
const DEFAULT_API_URL: &str = "http://127.0.0.1:8090";
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_target(false)
.init();
let api_url = std::env::args()
.nth(1)
.unwrap_or_else(|| DEFAULT_API_URL.to_string());
let refresh = Duration::from_millis(
std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_REFRESH_MS),
);
let collector = Collector::new(&api_url);
let mut dashboard = Dashboard::new();
// Enter alternate screen + hide cursor
print!("\x1B[?1049h\x1B[?25l");
io::stdout().flush()?;
let result = run_loop(&collector, &mut dashboard, refresh).await;
// Restore terminal: show cursor + leave alternate screen
print!("\x1B[?25h\x1B[?1049l");
io::stdout().flush()?;
result
}
async fn run_loop(
collector: &Collector,
dashboard: &mut Dashboard,
refresh: Duration,
) -> anyhow::Result<()> {
loop {
tokio::select! {
_ = signal::ctrl_c() => {
break;
}
_ = tokio::time::sleep(refresh) => {
let stats = collector.fetch().await;
dashboard.update(stats);
dashboard.render(&mut io::stdout())?;
}
}
}
Ok(())
}

50
hivemind/Cargo.toml Executable file
View file

@ -0,0 +1,50 @@
[package]
name = "hivemind"
version = "0.1.0"
edition = "2021"
description = "HiveMind Threat Mesh — decentralized P2P threat intelligence network"
[features]
default = []
# Feature-gated real Groth16 ZK-SNARK circuits (requires bellman + bls12_381).
# V1.0 uses ring-only commit-and-sign. Enable for Phase 2+.
zkp-groth16 = ["bellman", "bls12_381"]
# Feature-gated real FHE for encrypted gradient aggregation (requires tfhe).
# V1.0 uses AES-256-GCM (not homomorphic). Enable for Phase 3+.
fhe-real = ["tfhe"]
[dependencies]
common = { path = "../common", default-features = false, features = ["user"] }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
ring = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
http-body-util = { workspace = true }
nix = { workspace = true, features = ["user"] }
libp2p = { version = "0.54", features = [
"tokio",
"quic",
"noise",
"gossipsub",
"kad",
"mdns",
"macros",
"identify",
] }
# ZKP dependencies (feature-gated)
bellman = { version = "0.14", optional = true }
bls12_381 = { version = "0.8", optional = true }
# FHE dependency (feature-gated) — real homomorphic encryption
tfhe = { version = "0.8", optional = true, features = ["shortint", "x86_64-unix"] }
[[bin]]
name = "hivemind"
path = "src/main.rs"

232
hivemind/src/bootstrap.rs Executable file
View file

@ -0,0 +1,232 @@
/// Bootstrap protocol for HiveMind peer discovery.
///
/// Supports three discovery mechanisms:
/// 1. Hardcoded bootstrap nodes (compiled into the binary)
/// 2. User-configured bootstrap nodes (from hivemind.toml)
/// 3. mDNS (for local/LAN peer discovery)
///
/// ARCH: Bootstrap nodes will become Circuit Relay v2 servers
/// for NAT traversal once AutoNAT is integrated.
use anyhow::Context;
use libp2p::{Multiaddr, PeerId, Swarm};
use libp2p::multiaddr::Protocol;
use tracing::{info, warn};
use crate::config::HiveMindConfig;
use crate::transport::HiveMindBehaviour;
/// Check if a multiaddress points to a routable (non-loopback, non-unspecified) IP.
///
/// Rejects 127.0.0.0/8, ::1, 0.0.0.0, :: to prevent self-referencing connections
/// that cause "Unexpected peer ID" errors in Kademlia.
pub fn is_routable_addr(addr: &Multiaddr) -> bool {
let mut has_ip = false;
for proto in addr.iter() {
match proto {
Protocol::Ip4(ip) => {
has_ip = true;
if ip.is_loopback() || ip.is_unspecified() {
return false;
}
}
Protocol::Ip6(ip) => {
has_ip = true;
if ip.is_loopback() || ip.is_unspecified() {
return false;
}
}
_ => {}
}
}
has_ip
}
/// Built-in bootstrap nodes baked into the binary.
///
/// These are lightweight relay-only VPS instances that never go down.
/// They run `mode = "bootstrap"` and serve only as DHT entry points.
/// Users can disable them by setting `bootstrap.use_default_nodes = false`.
///
/// IMPORTANT: Update these when deploying new bootstrap infrastructure.
/// Format: "/dns4/<hostname>/udp/4001/quic-v1/p2p/<peer-id>"
///
/// Placeholder entries below — replace with real VPS PeerIds after
/// first deployment. The nodes won't connect until real PeerIds exist,
/// which is safe (they just log a warning and fall back to mDNS).
pub const DEFAULT_BOOTSTRAP_NODES: &[&str] = &[
// EU-West (Amsterdam) — primary bootstrap
// "/dns4/boot-eu1.blackwall.network/udp/4001/quic-v1/p2p/<PEER_ID>",
// US-East (New York) — secondary bootstrap
// "/dns4/boot-us1.blackwall.network/udp/4001/quic-v1/p2p/<PEER_ID>",
// AP-South (Singapore) — tertiary bootstrap
// "/dns4/boot-ap1.blackwall.network/udp/4001/quic-v1/p2p/<PEER_ID>",
];
/// Connect to bootstrap nodes (default + user-configured) and initiate
/// Kademlia bootstrap for full DHT peer discovery.
///
/// Bootstrap nodes are specified as multiaddresses in the config file.
/// Each address must include a `/p2p/<peer-id>` component.
pub fn connect_bootstrap_nodes(
swarm: &mut Swarm<HiveMindBehaviour>,
config: &HiveMindConfig,
local_peer_id: &PeerId,
) -> anyhow::Result<Vec<PeerId>> {
let mut seed_peers = Vec::new();
// --- 1. Built-in (hardcoded) bootstrap nodes ---
if config.bootstrap.use_default_nodes {
for addr_str in DEFAULT_BOOTSTRAP_NODES {
match try_add_bootstrap(swarm, addr_str, local_peer_id) {
Ok(Some(pid)) => seed_peers.push(pid),
Ok(None) => {} // self-referencing, skipped
Err(e) => {
warn!(
addr = addr_str,
error = %e,
"Failed to parse default bootstrap node — skipping"
);
}
}
}
}
// --- 2. User-configured bootstrap nodes ---
for node_addr_str in &config.bootstrap.nodes {
match try_add_bootstrap(swarm, node_addr_str, local_peer_id) {
Ok(Some(pid)) => seed_peers.push(pid),
Ok(None) => {}
Err(e) => {
warn!(
addr = node_addr_str,
error = %e,
"Failed to parse bootstrap node address — skipping"
);
}
}
}
if !seed_peers.is_empty() {
// Initiate Kademlia bootstrap to discover more peers
swarm
.behaviour_mut()
.kademlia
.bootstrap()
.map_err(|e| anyhow::anyhow!("Kademlia bootstrap failed: {e:?}"))?;
info!(count = seed_peers.len(), "Bootstrap initiated with known peers");
} else {
info!("No bootstrap nodes configured — relying on mDNS discovery");
}
Ok(seed_peers)
}
/// Try to add a single bootstrap node. Returns Ok(Some(PeerId)) if added,
/// Ok(None) if skipped (self-referencing), Err on parse failure.
fn try_add_bootstrap(
swarm: &mut Swarm<HiveMindBehaviour>,
addr_str: &str,
local_peer_id: &PeerId,
) -> anyhow::Result<Option<PeerId>> {
let (peer_id, addr) = parse_bootstrap_addr(addr_str)?;
// SECURITY: Reject self-referencing bootstrap entries
if peer_id == *local_peer_id {
warn!("Skipping bootstrap node that references self");
return Ok(None);
}
// SECURITY: Reject loopback/unspecified addresses
if !is_routable_addr(&addr) {
warn!(%addr, "Skipping bootstrap node with non-routable address");
return Ok(None);
}
swarm
.behaviour_mut()
.kademlia
.add_address(&peer_id, addr.clone());
swarm
.behaviour_mut()
.gossipsub
.add_explicit_peer(&peer_id);
info!(%peer_id, %addr, "Added bootstrap node");
Ok(Some(peer_id))
}
/// Parse a multiaddress string that includes a `/p2p/<peer-id>` suffix.
///
/// Example: `/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmPeer...`
fn parse_bootstrap_addr(addr_str: &str) -> anyhow::Result<(PeerId, Multiaddr)> {
let addr: Multiaddr = addr_str
.parse()
.context("Invalid multiaddress format")?;
// Extract PeerId from the /p2p/ component
let peer_id = addr
.iter()
.find_map(|proto| {
if let libp2p::multiaddr::Protocol::P2p(peer_id) = proto {
Some(peer_id)
} else {
None
}
})
.context("Bootstrap address must include /p2p/<peer-id> component")?;
// Strip the /p2p/ component from the address for Kademlia
let transport_addr: Multiaddr = addr
.iter()
.filter(|proto| !matches!(proto, libp2p::multiaddr::Protocol::P2p(_)))
.collect();
Ok((peer_id, transport_addr))
}
/// Handle mDNS discovery events — add discovered peers to both
/// Kademlia and GossipSub.
pub fn handle_mdns_discovered(
swarm: &mut Swarm<HiveMindBehaviour>,
peers: Vec<(PeerId, Multiaddr)>,
local_peer_id: &PeerId,
) {
for (peer_id, addr) in peers {
// SECURITY: Reject self-referencing entries
if peer_id == *local_peer_id {
continue;
}
// SECURITY: Reject loopback/unspecified addresses
if !is_routable_addr(&addr) {
warn!(%peer_id, %addr, "mDNS: skipping non-routable address");
continue;
}
swarm
.behaviour_mut()
.kademlia
.add_address(&peer_id, addr.clone());
swarm
.behaviour_mut()
.gossipsub
.add_explicit_peer(&peer_id);
info!(%peer_id, %addr, "mDNS: discovered local peer");
}
}
/// Handle mDNS expiry events — remove expired peers from GossipSub.
pub fn handle_mdns_expired(
swarm: &mut Swarm<HiveMindBehaviour>,
peers: Vec<(PeerId, Multiaddr)>,
) {
for (peer_id, _addr) in peers {
swarm
.behaviour_mut()
.gossipsub
.remove_explicit_peer(&peer_id);
info!(%peer_id, "mDNS: peer expired");
}
}

119
hivemind/src/config.rs Executable file
View file

@ -0,0 +1,119 @@
/// HiveMind configuration.
use serde::Deserialize;
use std::path::Path;
/// Node operating mode.
///
/// - `Full` — default: runs all modules (reputation, consensus, FL, metrics bridge).
/// - `Bootstrap` — lightweight relay: only Kademlia + GossipSub forwarding.
/// Designed for $5/mo VPS that hold tens of thousands of connections.
/// No DPI, XDP, AI, ZKP, or federated learning.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NodeMode {
#[default]
Full,
Bootstrap,
}
/// Top-level configuration for the HiveMind daemon.
#[derive(Debug, Clone, Deserialize)]
pub struct HiveMindConfig {
/// Node operating mode (full | bootstrap).
#[serde(default)]
pub mode: NodeMode,
/// Explicit path to the identity key file.
/// If omitted, defaults to ~/.blackwall/identity.key (or /etc/blackwall/identity.key as root).
#[serde(default)]
pub identity_key_path: Option<String>,
/// Network configuration.
#[serde(default)]
pub network: NetworkConfig,
/// Bootstrap configuration.
#[serde(default)]
pub bootstrap: BootstrapConfig,
}
/// Network-level settings.
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkConfig {
/// Listen address for QUIC transport (e.g., "/ip4/0.0.0.0/udp/4001/quic-v1").
#[serde(default = "default_listen_addr")]
pub listen_addr: String,
/// Maximum GossipSub message size in bytes.
#[serde(default = "default_max_message_size")]
pub max_message_size: usize,
/// GossipSub heartbeat interval in seconds.
#[serde(default = "default_heartbeat_secs")]
pub heartbeat_secs: u64,
/// Idle connection timeout in seconds.
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
}
/// Bootstrap node configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct BootstrapConfig {
/// List of additional user-specified bootstrap multiaddresses.
#[serde(default)]
pub nodes: Vec<String>,
/// Use built-in (hardcoded) bootstrap nodes. Default: true.
/// Set to false only for isolated/private meshes.
#[serde(default = "default_use_default_nodes")]
pub use_default_nodes: bool,
/// Enable mDNS for local peer discovery.
#[serde(default = "default_mdns_enabled")]
pub mdns_enabled: bool,
}
impl Default for NetworkConfig {
fn default() -> Self {
Self {
listen_addr: default_listen_addr(),
max_message_size: default_max_message_size(),
heartbeat_secs: default_heartbeat_secs(),
idle_timeout_secs: default_idle_timeout_secs(),
}
}
}
impl Default for BootstrapConfig {
fn default() -> Self {
Self {
nodes: Vec::new(),
use_default_nodes: default_use_default_nodes(),
mdns_enabled: default_mdns_enabled(),
}
}
}
fn default_listen_addr() -> String {
"/ip4/0.0.0.0/udp/4001/quic-v1".to_string()
}
fn default_max_message_size() -> usize {
common::hivemind::MAX_MESSAGE_SIZE
}
fn default_heartbeat_secs() -> u64 {
common::hivemind::GOSSIPSUB_HEARTBEAT_SECS
}
fn default_idle_timeout_secs() -> u64 {
60
}
fn default_mdns_enabled() -> bool {
true
}
fn default_use_default_nodes() -> bool {
true
}
/// Load HiveMind configuration from a TOML file.
pub fn load_config(path: &Path) -> anyhow::Result<HiveMindConfig> {
let content = std::fs::read_to_string(path)?;
let config: HiveMindConfig = toml::from_str(&content)?;
Ok(config)
}

252
hivemind/src/consensus.rs Executable file
View file

@ -0,0 +1,252 @@
/// Cross-validation of IoCs through N independent peers.
///
/// A single peer's IoC report is never trusted. The consensus module
/// tracks pending IoCs and requires at least `CROSS_VALIDATION_THRESHOLD`
/// independent peer confirmations before an IoC is accepted into the
/// local threat database.
use common::hivemind::{self, IoC};
use std::collections::HashMap;
use tracing::{debug, info};
/// Unique key for deduplicating IoC submissions.
///
/// Two IoCs are considered equivalent if they share the same type, IP,
/// and JA4 fingerprint.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct IoCKey {
ioc_type: u8,
ip: u32,
ja4: Option<String>,
}
impl From<&IoC> for IoCKey {
fn from(ioc: &IoC) -> Self {
Self {
ioc_type: ioc.ioc_type,
ip: ioc.ip,
ja4: ioc.ja4.clone(),
}
}
}
/// A pending IoC awaiting cross-validation from multiple peers.
#[derive(Clone, Debug)]
struct PendingIoC {
/// The IoC being validated.
ioc: IoC,
/// Set of peer pubkeys that confirmed this IoC (Ed25519, 32 bytes).
confirmations: Vec<[u8; 32]>,
/// Unix timestamp when this pending entry was created.
created_at: u64,
}
/// Result of submitting a peer's IoC confirmation.
#[derive(Debug, PartialEq, Eq)]
pub enum ConsensusResult {
/// IoC accepted — threshold reached. Contains final confirmation count.
Accepted(usize),
/// IoC recorded but threshold not yet met. Contains current count.
Pending(usize),
/// Duplicate confirmation from the same peer — ignored.
DuplicatePeer,
/// The IoC expired before reaching consensus.
Expired,
}
/// Manages cross-validation of IoCs across independent peers.
pub struct ConsensusEngine {
/// Pending IoCs keyed by their dedup identity.
pending: HashMap<IoCKey, PendingIoC>,
/// IoCs that reached consensus (for querying accepted threats).
accepted: Vec<IoC>,
}
impl Default for ConsensusEngine {
fn default() -> Self {
Self::new()
}
}
impl ConsensusEngine {
/// Create a new consensus engine.
pub fn new() -> Self {
Self {
pending: HashMap::new(),
accepted: Vec::new(),
}
}
/// Submit a peer's IoC report for cross-validation.
///
/// Returns the consensus result: whether threshold was met, pending, or duplicate.
pub fn submit_ioc(
&mut self,
ioc: &IoC,
reporter_pubkey: &[u8; 32],
) -> ConsensusResult {
let key = IoCKey::from(ioc);
let now = now_secs();
// Check if this IoC is already pending
if let Some(pending) = self.pending.get_mut(&key) {
// Check expiry
if now.saturating_sub(pending.created_at) > hivemind::CONSENSUS_TIMEOUT_SECS {
debug!("Pending IoC expired — removing");
self.pending.remove(&key);
return ConsensusResult::Expired;
}
// Check for duplicate peer confirmation
if pending.confirmations.iter().any(|pk| pk == reporter_pubkey) {
debug!("Duplicate confirmation from same peer — ignoring");
return ConsensusResult::DuplicatePeer;
}
pending.confirmations.push(*reporter_pubkey);
let count = pending.confirmations.len();
if count >= hivemind::CROSS_VALIDATION_THRESHOLD {
// Consensus reached — move to accepted
let mut accepted_ioc = pending.ioc.clone();
accepted_ioc.confirmations = count as u32;
info!(
count,
ioc_type = accepted_ioc.ioc_type,
"IoC reached consensus — accepted"
);
self.accepted.push(accepted_ioc);
self.pending.remove(&key);
return ConsensusResult::Accepted(count);
}
debug!(count, threshold = hivemind::CROSS_VALIDATION_THRESHOLD, "IoC pending");
ConsensusResult::Pending(count)
} else {
// First report of this IoC
let pending = PendingIoC {
ioc: ioc.clone(),
confirmations: vec![*reporter_pubkey],
created_at: now,
};
self.pending.insert(key, pending);
debug!("New IoC submitted — awaiting cross-validation");
ConsensusResult::Pending(1)
}
}
/// Drain all newly accepted IoCs. Returns and clears the accepted list.
pub fn drain_accepted(&mut self) -> Vec<IoC> {
std::mem::take(&mut self.accepted)
}
/// Evict expired pending IoCs. Returns the number removed.
pub fn evict_expired(&mut self) -> usize {
let now = now_secs();
let before = self.pending.len();
self.pending.retain(|_, pending| {
now.saturating_sub(pending.created_at) <= hivemind::CONSENSUS_TIMEOUT_SECS
});
let removed = before - self.pending.len();
if removed > 0 {
info!(removed, "Evicted expired pending IoCs");
}
removed
}
/// Number of IoCs currently awaiting consensus.
pub fn pending_count(&self) -> usize {
self.pending.len()
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ioc() -> IoC {
IoC {
ioc_type: 0,
severity: 3,
ip: 0xC0A80001, // 192.168.0.1
ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_string()),
entropy_score: Some(7500),
description: "Test malicious IP".to_string(),
first_seen: 1700000000,
confirmations: 0,
zkp_proof: Vec::new(),
}
}
fn peer_key(id: u8) -> [u8; 32] {
let mut key = [0u8; 32];
key[0] = id;
key
}
#[test]
fn single_report_stays_pending() {
let mut engine = ConsensusEngine::new();
let ioc = make_ioc();
let result = engine.submit_ioc(&ioc, &peer_key(1));
assert_eq!(result, ConsensusResult::Pending(1));
assert_eq!(engine.pending_count(), 1);
}
#[test]
fn duplicate_peer_ignored() {
let mut engine = ConsensusEngine::new();
let ioc = make_ioc();
engine.submit_ioc(&ioc, &peer_key(1));
let result = engine.submit_ioc(&ioc, &peer_key(1));
assert_eq!(result, ConsensusResult::DuplicatePeer);
}
#[test]
fn consensus_reached_at_threshold() {
let mut engine = ConsensusEngine::new();
let ioc = make_ioc();
for i in 1..hivemind::CROSS_VALIDATION_THRESHOLD {
let result = engine.submit_ioc(&ioc, &peer_key(i as u8));
assert_eq!(result, ConsensusResult::Pending(i));
}
let result = engine.submit_ioc(
&ioc,
&peer_key(hivemind::CROSS_VALIDATION_THRESHOLD as u8),
);
assert_eq!(
result,
ConsensusResult::Accepted(hivemind::CROSS_VALIDATION_THRESHOLD)
);
assert_eq!(engine.pending_count(), 0);
let accepted = engine.drain_accepted();
assert_eq!(accepted.len(), 1);
assert_eq!(
accepted[0].confirmations,
hivemind::CROSS_VALIDATION_THRESHOLD as u32
);
}
#[test]
fn different_iocs_tracked_separately() {
let mut engine = ConsensusEngine::new();
let mut ioc1 = make_ioc();
ioc1.ip = 1;
let mut ioc2 = make_ioc();
ioc2.ip = 2;
engine.submit_ioc(&ioc1, &peer_key(1));
engine.submit_ioc(&ioc2, &peer_key(1));
assert_eq!(engine.pending_count(), 2);
}
}

439
hivemind/src/crypto/fhe.rs Executable file
View file

@ -0,0 +1,439 @@
/// FHE (Fully Homomorphic Encryption) — gradient privacy via AES-256-GCM.
///
/// # Privacy Invariant
/// Raw gradients NEVER leave the node. Only ciphertext is transmitted.
///
/// # Implementation
/// - **v0 (legacy stub)**: `SFHE` prefix + raw f32 LE bytes (no real encryption).
/// - **v1 (encrypted)**: `RFHE` prefix + AES-256-GCM encrypted payload.
///
/// True homomorphic operations (add/multiply on ciphertext) require `tfhe-rs`
/// and are feature-gated for Phase 2+. Current encryption provides
/// confidentiality at rest and in transit but is NOT homomorphic —
/// the aggregator must decrypt before aggregating.
///
/// # Naming Convention
/// The primary type is `GradientCryptoCtx` (accurate to current implementation).
/// `FheContext` is a type alias preserved for backward compatibility and will
/// become the real FHE wrapper when `tfhe-rs` is integrated in Phase 2+.
use ring::aead::{self, Aad, BoundKey, Nonce, NonceSequence, NONCE_LEN};
use ring::rand::{SecureRandom, SystemRandom};
use tracing::{debug, warn};
/// Magic bytes identifying a v0 stub (unencrypted) payload.
const STUB_FHE_MAGIC: &[u8; 4] = b"SFHE";
/// Magic bytes identifying a v1 AES-256-GCM encrypted payload.
const REAL_FHE_MAGIC: &[u8; 4] = b"RFHE";
/// Nonce size for AES-256-GCM (96 bits).
const NONCE_SIZE: usize = NONCE_LEN;
/// Overhead: magic(4) + nonce(12) + GCM tag(16) = 32 bytes.
const ENCRYPTION_OVERHEAD: usize = 4 + NONCE_SIZE + 16;
/// Single-use nonce for AES-256-GCM sealing operations.
struct OneNonceSequence(Option<aead::Nonce>);
impl OneNonceSequence {
fn new(nonce_bytes: [u8; NONCE_SIZE]) -> Self {
Self(Some(aead::Nonce::assume_unique_for_key(nonce_bytes)))
}
}
impl NonceSequence for OneNonceSequence {
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
self.0.take().ok_or(ring::error::Unspecified)
}
}
/// Gradient encryption context using AES-256-GCM.
///
/// Provides confidentiality for gradient vectors transmitted over GossipSub.
/// NOT truly homomorphic — aggregator must decrypt before aggregating.
/// Will be replaced by real FHE (`tfhe-rs`) in Phase 2+.
pub struct GradientCryptoCtx {
/// Raw AES-256-GCM key material (32 bytes).
key_bytes: Vec<u8>,
/// Whether this context has been initialized with keys.
initialized: bool,
}
/// Backward-compatible alias. Will point to a real FHE wrapper in Phase 2+.
pub type FheContext = GradientCryptoCtx;
impl Default for GradientCryptoCtx {
fn default() -> Self {
Self::new_encrypted().expect("GradientCryptoCtx initialization failed")
}
}
impl GradientCryptoCtx {
/// Create a new context with a fresh AES-256-GCM key.
///
/// Generates a random 256-bit key using the system CSPRNG.
pub fn new_encrypted() -> Result<Self, FheError> {
let rng = SystemRandom::new();
let mut key_bytes = vec![0u8; 32];
rng.fill(&mut key_bytes)
.map_err(|_| FheError::KeyGenerationFailed)?;
debug!("FHE context initialized (AES-256-GCM)");
Ok(Self {
key_bytes,
initialized: true,
})
}
/// Create a legacy stub context (no real encryption).
///
/// For backward compatibility only. New code should use `new_encrypted()`.
pub fn new() -> Self {
debug!("FHE context initialized (stub — no real encryption)");
Self {
key_bytes: Vec::new(),
initialized: true,
}
}
/// Create FHE context from existing key material.
///
/// # Arguments
/// * `key_bytes` — 32-byte AES-256-GCM key
pub fn from_key(key_bytes: &[u8]) -> Result<Self, FheError> {
if key_bytes.len() != 32 {
return Err(FheError::InvalidPayload);
}
Ok(Self {
key_bytes: key_bytes.to_vec(),
initialized: true,
})
}
/// Encrypt gradient vector for safe transmission over GossipSub.
///
/// # Privacy Contract
/// The returned bytes are AES-256-GCM encrypted — not reversible
/// without the symmetric key.
///
/// # Format
/// `RFHE(4B) || nonce(12B) || ciphertext+tag`
///
/// Falls back to stub format if no key material is available.
pub fn encrypt_gradients(&self, gradients: &[f32]) -> Result<Vec<u8>, FheError> {
if !self.initialized {
return Err(FheError::Uninitialized);
}
// Stub mode — no key material
if self.key_bytes.is_empty() {
return self.encrypt_stub(gradients);
}
// Serialize gradients to raw bytes
let mut plaintext = Vec::with_capacity(gradients.len() * 4);
for &g in gradients {
plaintext.extend_from_slice(&g.to_le_bytes());
}
// Generate random nonce
let rng = SystemRandom::new();
let mut nonce_bytes = [0u8; NONCE_SIZE];
rng.fill(&mut nonce_bytes)
.map_err(|_| FheError::EncryptionFailed)?;
// Create sealing key
let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, &self.key_bytes)
.map_err(|_| FheError::EncryptionFailed)?;
let nonce_seq = OneNonceSequence::new(nonce_bytes);
let mut sealing_key = aead::SealingKey::new(unbound_key, nonce_seq);
// Encrypt in-place (appends GCM tag)
sealing_key
.seal_in_place_append_tag(Aad::empty(), &mut plaintext)
.map_err(|_| FheError::EncryptionFailed)?;
// Build output: RFHE || nonce || ciphertext+tag
let mut payload = Vec::with_capacity(ENCRYPTION_OVERHEAD + plaintext.len());
payload.extend_from_slice(REAL_FHE_MAGIC);
payload.extend_from_slice(&nonce_bytes);
payload.extend_from_slice(&plaintext);
debug!(
gradient_count = gradients.len(),
payload_size = payload.len(),
"gradients encrypted (AES-256-GCM)"
);
Ok(payload)
}
/// Legacy stub encryption (no real crypto).
fn encrypt_stub(&self, gradients: &[f32]) -> Result<Vec<u8>, FheError> {
let mut payload = Vec::with_capacity(4 + gradients.len() * 4);
payload.extend_from_slice(STUB_FHE_MAGIC);
for &g in gradients {
payload.extend_from_slice(&g.to_le_bytes());
}
debug!(
gradient_count = gradients.len(),
"gradients serialized (stub — no encryption)"
);
Ok(payload)
}
/// Decrypt a gradient payload received from GossipSub.
///
/// Supports both RFHE (encrypted) and SFHE (legacy stub) payloads.
pub fn decrypt_gradients(&self, payload: &[u8]) -> Result<Vec<f32>, FheError> {
if !self.initialized {
return Err(FheError::Uninitialized);
}
if payload.len() < 4 {
return Err(FheError::InvalidPayload);
}
match &payload[..4] {
b"RFHE" => self.decrypt_real(payload),
b"SFHE" => self.decrypt_stub(payload),
_ => {
warn!("unknown FHE payload format");
Err(FheError::InvalidPayload)
}
}
}
/// Decrypt an AES-256-GCM encrypted payload.
fn decrypt_real(&self, payload: &[u8]) -> Result<Vec<f32>, FheError> {
if self.key_bytes.is_empty() {
return Err(FheError::Uninitialized);
}
// Minimum: magic(4) + nonce(12) + tag(16) = 32 bytes
if payload.len() < ENCRYPTION_OVERHEAD {
return Err(FheError::InvalidPayload);
}
let nonce_bytes: [u8; NONCE_SIZE] = payload[4..4 + NONCE_SIZE]
.try_into()
.map_err(|_| FheError::InvalidPayload)?;
let mut ciphertext = payload[4 + NONCE_SIZE..].to_vec();
let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, &self.key_bytes)
.map_err(|_| FheError::DecryptionFailed)?;
let nonce_seq = OneNonceSequence::new(nonce_bytes);
let mut opening_key = aead::OpeningKey::new(unbound_key, nonce_seq);
let plaintext = opening_key
.open_in_place(Aad::empty(), &mut ciphertext)
.map_err(|_| FheError::DecryptionFailed)?;
if !plaintext.len().is_multiple_of(4) {
return Err(FheError::InvalidPayload);
}
let gradients: Vec<f32> = plaintext
.chunks_exact(4)
.map(|chunk| {
let bytes: [u8; 4] = chunk.try_into().expect("chunk is 4 bytes");
f32::from_le_bytes(bytes)
})
.collect();
debug!(gradient_count = gradients.len(), "gradients decrypted (AES-256-GCM)");
Ok(gradients)
}
/// Decrypt a legacy stub payload (just deserialization, no crypto).
fn decrypt_stub(&self, payload: &[u8]) -> Result<Vec<f32>, FheError> {
let data = &payload[4..];
if !data.len().is_multiple_of(4) {
return Err(FheError::InvalidPayload);
}
let gradients: Vec<f32> = data
.chunks_exact(4)
.map(|chunk| {
let bytes: [u8; 4] = chunk.try_into().expect("chunk is 4 bytes");
f32::from_le_bytes(bytes)
})
.collect();
debug!(gradient_count = gradients.len(), "gradients deserialized (stub)");
Ok(gradients)
}
/// Check if this is a stub (unencrypted) implementation.
pub fn is_stub(&self) -> bool {
self.key_bytes.is_empty()
}
}
/// Errors from FHE operations.
#[derive(Debug, PartialEq, Eq)]
pub enum FheError {
/// FHE context not initialized (no keys generated).
Uninitialized,
/// Payload format is invalid or corrupted.
InvalidPayload,
/// Key generation failed (CSPRNG error).
KeyGenerationFailed,
/// AES-256-GCM encryption failed.
EncryptionFailed,
/// AES-256-GCM decryption failed (tampered or wrong key).
DecryptionFailed,
}
impl std::fmt::Display for FheError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FheError::Uninitialized => write!(f, "FHE context not initialized"),
FheError::InvalidPayload => write!(f, "invalid FHE payload format"),
FheError::KeyGenerationFailed => write!(f, "FHE key generation failed"),
FheError::EncryptionFailed => write!(f, "AES-256-GCM encryption failed"),
FheError::DecryptionFailed => write!(f, "AES-256-GCM decryption failed"),
}
}
}
impl std::error::Error for FheError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypted_roundtrip() {
let ctx = FheContext::new_encrypted().expect("init");
let gradients = vec![1.0_f32, -0.5, 0.0, 3.14, -2.718];
let encrypted = ctx.encrypt_gradients(&gradients).expect("encrypt");
let decrypted = ctx.decrypt_gradients(&encrypted).expect("decrypt");
assert_eq!(gradients, decrypted);
}
#[test]
fn encrypted_has_rfhe_prefix() {
let ctx = FheContext::new_encrypted().expect("init");
let encrypted = ctx.encrypt_gradients(&[1.0, 2.0]).expect("encrypt");
assert_eq!(&encrypted[..4], REAL_FHE_MAGIC);
}
#[test]
fn encrypted_payload_larger_than_stub() {
let ctx_real = FheContext::new_encrypted().expect("init");
let ctx_stub = FheContext::new();
let grads = vec![1.0_f32; 10];
let real = ctx_real.encrypt_gradients(&grads).expect("encrypt");
let stub = ctx_stub.encrypt_gradients(&grads).expect("encrypt");
// Real encryption adds nonce(12) + tag(16) overhead
assert!(real.len() > stub.len());
}
#[test]
fn wrong_key_fails_decryption() {
let ctx1 = FheContext::new_encrypted().expect("init");
let ctx2 = FheContext::new_encrypted().expect("init");
let encrypted = ctx1.encrypt_gradients(&[1.0, 2.0]).expect("encrypt");
assert_eq!(
ctx2.decrypt_gradients(&encrypted),
Err(FheError::DecryptionFailed),
);
}
#[test]
fn tampered_ciphertext_fails() {
let ctx = FheContext::new_encrypted().expect("init");
let mut encrypted = ctx.encrypt_gradients(&[1.0, 2.0]).expect("encrypt");
// Tamper with the ciphertext (after nonce)
let idx = 4 + NONCE_SIZE + 1;
if idx < encrypted.len() {
encrypted[idx] ^= 0xFF;
}
assert_eq!(
ctx.decrypt_gradients(&encrypted),
Err(FheError::DecryptionFailed),
);
}
#[test]
fn stub_roundtrip() {
let ctx = FheContext::new();
let gradients = vec![1.0_f32, -0.5, 0.0, 3.14, -2.718];
let encrypted = ctx.encrypt_gradients(&gradients).expect("encrypt");
let decrypted = ctx.decrypt_gradients(&encrypted).expect("decrypt");
assert_eq!(gradients, decrypted);
}
#[test]
fn stub_has_sfhe_prefix() {
let ctx = FheContext::new();
let encrypted = ctx.encrypt_gradients(&[1.0]).expect("encrypt");
assert_eq!(&encrypted[..4], STUB_FHE_MAGIC);
}
#[test]
fn rejects_invalid_payload() {
let ctx = FheContext::new_encrypted().expect("init");
assert_eq!(
ctx.decrypt_gradients(&[0xDE, 0xAD]),
Err(FheError::InvalidPayload),
);
}
#[test]
fn rejects_wrong_magic() {
let ctx = FheContext::new_encrypted().expect("init");
let bad = b"BADx\x00\x00\x80\x3f";
assert_eq!(
ctx.decrypt_gradients(bad),
Err(FheError::InvalidPayload),
);
}
#[test]
fn empty_gradients_encrypted() {
let ctx = FheContext::new_encrypted().expect("init");
let encrypted = ctx.encrypt_gradients(&[]).expect("encrypt");
let decrypted = ctx.decrypt_gradients(&encrypted).expect("decrypt");
assert!(decrypted.is_empty());
}
#[test]
fn is_stub_reports_correctly() {
let stub = FheContext::new();
assert!(stub.is_stub());
let real = FheContext::new_encrypted().expect("init");
assert!(!real.is_stub());
}
#[test]
fn from_key_roundtrip() {
let ctx1 = FheContext::new_encrypted().expect("init");
let encrypted = ctx1.encrypt_gradients(&[42.0, -1.0]).expect("encrypt");
// Reconstruct context from same key material
let ctx2 = FheContext::from_key(&ctx1.key_bytes).expect("from_key");
let decrypted = ctx2.decrypt_gradients(&encrypted).expect("decrypt");
assert_eq!(decrypted, vec![42.0, -1.0]);
}
#[test]
fn real_ctx_can_read_stub_payload() {
let stub = FheContext::new();
let encrypted = stub.encrypt_gradients(&[1.0, 2.0]).expect("encrypt");
let real = FheContext::new_encrypted().expect("init");
let decrypted = real.decrypt_gradients(&encrypted).expect("decrypt");
assert_eq!(decrypted, vec![1.0, 2.0]);
}
#[test]
fn different_nonces_produce_different_ciphertext() {
let ctx = FheContext::new_encrypted().expect("init");
let e1 = ctx.encrypt_gradients(&[1.0]).expect("encrypt");
let e2 = ctx.encrypt_gradients(&[1.0]).expect("encrypt");
// Different nonces → different ciphertext
assert_ne!(e1, e2);
}
}

187
hivemind/src/crypto/fhe_real.rs Executable file
View file

@ -0,0 +1,187 @@
//! Real Fully Homomorphic Encryption using TFHE-rs.
//!
//! Feature-gated behind `fhe-real`. Provides true homomorphic operations
//! on encrypted gradient vectors — the aggregator can sum encrypted gradients
//! WITHOUT decrypting them. This eliminates the trust requirement on the
//! aggregator node in federated learning.
//!
//! # Architecture
//! - Each node generates a `ClientKey` (private) and `ServerKey` (public).
//! - Gradients are encrypted with `ClientKey` → `FheInt32` ciphertext.
//! - The aggregator uses `ServerKey` to homomorphically add ciphertexts.
//! - Only the originating node can decrypt with its `ClientKey`.
//!
//! # Performance
//! TFHE operations are CPU-intensive. For 8GB VRAM systems:
//! - Batch gradients into chunks of 64 before encryption
//! - Use shortint parameters for efficiency
//! - Aggregation is async to avoid blocking the event loop
use tfhe::prelude::*;
use tfhe::{generate_keys, set_server_key, ClientKey, ConfigBuilder, FheInt32, ServerKey};
use tracing::{debug, info, warn};
/// Real FHE context using TFHE-rs for homomorphic gradient operations.
pub struct RealFheContext {
client_key: ClientKey,
server_key: ServerKey,
}
/// Errors from real FHE operations.
#[derive(Debug)]
pub enum RealFheError {
/// Key generation failed.
KeyGenerationFailed(String),
/// Encryption failed.
EncryptionFailed(String),
/// Decryption failed.
DecryptionFailed(String),
/// Homomorphic operation failed.
HomomorphicOpFailed(String),
}
impl std::fmt::Display for RealFheError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::KeyGenerationFailed(e) => write!(f, "FHE key generation failed: {}", e),
Self::EncryptionFailed(e) => write!(f, "FHE encryption failed: {}", e),
Self::DecryptionFailed(e) => write!(f, "FHE decryption failed: {}", e),
Self::HomomorphicOpFailed(e) => write!(f, "FHE homomorphic op failed: {}", e),
}
}
}
impl std::error::Error for RealFheError {}
impl RealFheContext {
/// Generate fresh FHE keys (expensive — ~seconds on first call).
///
/// The `ServerKey` should be distributed to aggregator nodes.
/// The `ClientKey` stays private on this node.
pub fn new() -> Result<Self, RealFheError> {
info!("generating TFHE keys (this may take a moment)...");
let config = ConfigBuilder::default().build();
let (client_key, server_key) = generate_keys(config);
info!("TFHE keys generated — real FHE enabled");
Ok(Self {
client_key,
server_key,
})
}
/// Get a reference to the server key (for distribution to aggregators).
pub fn server_key(&self) -> &ServerKey {
&self.server_key
}
/// Encrypt gradient values as FHE ciphertexts.
///
/// Quantizes f32 gradients to i32 (×10000 for 4 decimal places precision)
/// before encryption. Returns serialized ciphertexts.
pub fn encrypt_gradients(&self, gradients: &[f32]) -> Result<Vec<Vec<u8>>, RealFheError> {
let mut encrypted = Vec::with_capacity(gradients.len());
for (i, &g) in gradients.iter().enumerate() {
// Quantize: f32 → i32 with 4dp precision
let quantized = (g * 10000.0) as i32;
let ct = FheInt32::encrypt(quantized, &self.client_key);
let bytes = bincode::serialize(&ct)
.map_err(|e| RealFheError::EncryptionFailed(e.to_string()))?;
encrypted.push(bytes);
if i % 64 == 0 && i > 0 {
debug!(progress = i, total = gradients.len(), "FHE encryption progress");
}
}
debug!(count = gradients.len(), "gradients encrypted with TFHE");
Ok(encrypted)
}
/// Decrypt FHE ciphertexts back to gradient values.
pub fn decrypt_gradients(&self, ciphertexts: &[Vec<u8>]) -> Result<Vec<f32>, RealFheError> {
let mut gradients = Vec::with_capacity(ciphertexts.len());
for ct_bytes in ciphertexts {
let ct: FheInt32 = bincode::deserialize(ct_bytes)
.map_err(|e| RealFheError::DecryptionFailed(e.to_string()))?;
let quantized: i32 = ct.decrypt(&self.client_key);
gradients.push(quantized as f32 / 10000.0);
}
debug!(count = gradients.len(), "gradients decrypted from TFHE");
Ok(gradients)
}
/// Homomorphically add two encrypted gradient vectors (element-wise).
///
/// This is the core FL aggregation operation — runs on the aggregator
/// node WITHOUT access to the client key (plaintext never exposed).
pub fn aggregate_encrypted(
&self,
a: &[Vec<u8>],
b: &[Vec<u8>],
) -> Result<Vec<Vec<u8>>, RealFheError> {
if a.len() != b.len() {
return Err(RealFheError::HomomorphicOpFailed(
"gradient vector length mismatch".into(),
));
}
// Set server key for homomorphic operations
set_server_key(self.server_key.clone());
let mut result = Vec::with_capacity(a.len());
for (ct_a, ct_b) in a.iter().zip(b.iter()) {
let a_ct: FheInt32 = bincode::deserialize(ct_a)
.map_err(|e| RealFheError::HomomorphicOpFailed(e.to_string()))?;
let b_ct: FheInt32 = bincode::deserialize(ct_b)
.map_err(|e| RealFheError::HomomorphicOpFailed(e.to_string()))?;
// Homomorphic addition — no decryption needed!
let sum = a_ct + b_ct;
let bytes = bincode::serialize(&sum)
.map_err(|e| RealFheError::HomomorphicOpFailed(e.to_string()))?;
result.push(bytes);
}
debug!(count = a.len(), "encrypted gradients aggregated homomorphically");
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fhe_encrypt_decrypt_roundtrip() {
let ctx = RealFheContext::new().expect("key gen");
let gradients = vec![1.5f32, -0.25, 0.0, 3.1415];
let encrypted = ctx.encrypt_gradients(&gradients).expect("encrypt");
let decrypted = ctx.decrypt_gradients(&encrypted).expect("decrypt");
// Check within quantization tolerance (4dp = 0.0001)
for (orig, dec) in gradients.iter().zip(decrypted.iter()) {
assert!((orig - dec).abs() < 0.001, "mismatch: {} vs {}", orig, dec);
}
}
#[test]
fn fhe_homomorphic_addition() {
let ctx = RealFheContext::new().expect("key gen");
let a = vec![1.0f32, 2.0, 3.0];
let b = vec![4.0f32, 5.0, 6.0];
let enc_a = ctx.encrypt_gradients(&a).expect("encrypt a");
let enc_b = ctx.encrypt_gradients(&b).expect("encrypt b");
let enc_sum = ctx.aggregate_encrypted(&enc_a, &enc_b).expect("aggregate");
let sum = ctx.decrypt_gradients(&enc_sum).expect("decrypt sum");
for (i, expected) in [5.0f32, 7.0, 9.0].iter().enumerate() {
assert!(
(sum[i] - expected).abs() < 0.001,
"idx {}: {} vs {}",
i,
sum[i],
expected,
);
}
}
}

13
hivemind/src/crypto/mod.rs Executable file
View file

@ -0,0 +1,13 @@
//! Cryptographic primitives for HiveMind.
//!
//! Contains gradient encryption:
//! - `fhe` — AES-256-GCM wrapper (`GradientCryptoCtx`) used in V1.0
//! - `fhe_real` — Real TFHE-based homomorphic encryption (feature-gated `fhe-real`)
//!
//! The `FheContext` alias in `fhe` will transparently upgrade to TFHE
//! when the `fhe-real` feature is enabled.
pub mod fhe;
#[cfg(feature = "fhe-real")]
pub mod fhe_real;

145
hivemind/src/dht.rs Executable file
View file

@ -0,0 +1,145 @@
/// Kademlia DHT operations for HiveMind.
///
/// Provides structured peer routing and distributed IoC storage using
/// Kademlia's XOR distance metric. IoC records are stored in the DHT
/// with TTL-based expiry to prevent stale threat data.
use libp2p::{kad, Multiaddr, PeerId, Swarm};
use tracing::{debug, info, warn};
use crate::bootstrap;
use crate::transport::HiveMindBehaviour;
/// Store a threat indicator in the DHT.
///
/// The key is the serialized IoC identifier (e.g., JA4 hash or IP).
/// Record is replicated to the `k` closest peers (Quorum::Majority).
pub fn put_ioc_record(
swarm: &mut Swarm<HiveMindBehaviour>,
key_bytes: &[u8],
value: Vec<u8>,
local_peer_id: PeerId,
) -> anyhow::Result<kad::QueryId> {
let key = kad::RecordKey::new(&key_bytes);
let record = kad::Record {
key: key.clone(),
value,
publisher: Some(local_peer_id),
expires: None, // Managed by MemoryStore TTL
};
let query_id = swarm
.behaviour_mut()
.kademlia
.put_record(record, kad::Quorum::Majority)
.map_err(|e| anyhow::anyhow!("DHT put failed: {e:?}"))?;
debug!(?query_id, "DHT PUT initiated for IoC record");
Ok(query_id)
}
/// Look up a threat indicator in the DHT by key.
pub fn get_ioc_record(
swarm: &mut Swarm<HiveMindBehaviour>,
key_bytes: &[u8],
) -> kad::QueryId {
let key = kad::RecordKey::new(&key_bytes);
let query_id = swarm.behaviour_mut().kademlia.get_record(key);
debug!(?query_id, "DHT GET initiated for IoC record");
query_id
}
/// Add a known peer address to the Kademlia routing table.
///
/// Rejects self-referencing entries (peer pointing to itself).
pub fn add_peer(
swarm: &mut Swarm<HiveMindBehaviour>,
peer_id: &PeerId,
addr: Multiaddr,
local_peer_id: &PeerId,
) {
// SECURITY: Reject self-referencing entries
if peer_id == local_peer_id {
warn!(%peer_id, "Rejected self-referencing k-bucket entry");
return;
}
// SECURITY: Reject loopback/unspecified addresses
if !bootstrap::is_routable_addr(&addr) {
warn!(%peer_id, %addr, "Rejected non-routable address for k-bucket");
return;
}
swarm
.behaviour_mut()
.kademlia
.add_address(peer_id, addr.clone());
debug!(%peer_id, %addr, "Added peer to Kademlia routing table");
}
/// Initiate a Kademlia bootstrap to populate routing table.
pub fn bootstrap(swarm: &mut Swarm<HiveMindBehaviour>) -> anyhow::Result<kad::QueryId> {
let query_id = swarm
.behaviour_mut()
.kademlia
.bootstrap()
.map_err(|e| anyhow::anyhow!("Kademlia bootstrap failed: {e:?}"))?;
info!(?query_id, "Kademlia bootstrap initiated");
Ok(query_id)
}
/// Handle a Kademlia event from the swarm event loop.
pub fn handle_kad_event(event: kad::Event) {
match event {
kad::Event::OutboundQueryProgressed {
id, result, step, ..
} => match result {
kad::QueryResult::GetRecord(Ok(kad::GetRecordOk::FoundRecord(
kad::PeerRecord { record, .. },
))) => {
info!(
?id,
key_len = record.key.as_ref().len(),
value_len = record.value.len(),
"DHT record found"
);
}
kad::QueryResult::GetRecord(Err(e)) => {
warn!(?id, ?e, "DHT GET failed");
}
kad::QueryResult::PutRecord(Ok(kad::PutRecordOk { key })) => {
info!(?id, key_len = key.as_ref().len(), "DHT PUT succeeded");
}
kad::QueryResult::PutRecord(Err(e)) => {
warn!(?id, ?e, "DHT PUT failed");
}
kad::QueryResult::Bootstrap(Ok(kad::BootstrapOk {
peer,
num_remaining,
})) => {
info!(
?id,
%peer,
num_remaining,
step = step.count,
"Kademlia bootstrap progress"
);
}
kad::QueryResult::Bootstrap(Err(e)) => {
warn!(?id, ?e, "Kademlia bootstrap failed");
}
_ => {
debug!(?id, "Kademlia query progressed");
}
},
kad::Event::RoutingUpdated {
peer, addresses, ..
} => {
debug!(%peer, addr_count = addresses.len(), "Routing table updated");
}
kad::Event::RoutablePeer { peer, address } => {
debug!(%peer, %address, "New routable peer discovered");
}
_ => {}
}
}

232
hivemind/src/gossip.rs Executable file
View file

@ -0,0 +1,232 @@
/// GossipSub operations for HiveMind.
///
/// Provides epidemic broadcast of IoC reports across the mesh.
/// All messages are authenticated (Ed25519 signed) and deduplicated
/// via content-hash message IDs.
use common::hivemind::{self, IoC, ThreatReport};
use libp2p::{gossipsub, PeerId, Swarm};
use tracing::{debug, info, warn};
use crate::transport::HiveMindBehaviour;
/// Subscribe to all HiveMind GossipSub topics.
pub fn subscribe_all(swarm: &mut Swarm<HiveMindBehaviour>) -> anyhow::Result<()> {
let topics = [
hivemind::topics::IOC_TOPIC,
hivemind::topics::JA4_TOPIC,
hivemind::topics::HEARTBEAT_TOPIC,
hivemind::topics::A2A_VIOLATIONS_TOPIC,
];
for topic_str in &topics {
let topic = gossipsub::IdentTopic::new(*topic_str);
swarm
.behaviour_mut()
.gossipsub
.subscribe(&topic)
.map_err(|e| anyhow::anyhow!("Failed to subscribe to {topic_str}: {e}"))?;
info!(topic = topic_str, "Subscribed to GossipSub topic");
}
Ok(())
}
/// Publish a ThreatReport to the IoC topic.
pub fn publish_threat_report(
swarm: &mut Swarm<HiveMindBehaviour>,
report: &ThreatReport,
) -> anyhow::Result<gossipsub::MessageId> {
let topic = gossipsub::IdentTopic::new(hivemind::topics::IOC_TOPIC);
let data = serde_json::to_vec(report)
.map_err(|e| anyhow::anyhow!("Failed to serialize ThreatReport: {e}"))?;
// SECURITY: Enforce maximum message size
if data.len() > hivemind::MAX_MESSAGE_SIZE {
anyhow::bail!(
"ThreatReport exceeds max message size ({} > {})",
data.len(),
hivemind::MAX_MESSAGE_SIZE
);
}
let msg_id = swarm
.behaviour_mut()
.gossipsub
.publish(topic, data)
.map_err(|e| anyhow::anyhow!("Failed to publish ThreatReport: {e}"))?;
debug!(?msg_id, "Published ThreatReport to IoC topic");
Ok(msg_id)
}
/// Publish a single IoC to the IoC topic as a lightweight message.
pub fn publish_ioc(
swarm: &mut Swarm<HiveMindBehaviour>,
ioc: &IoC,
) -> anyhow::Result<gossipsub::MessageId> {
let topic = gossipsub::IdentTopic::new(hivemind::topics::IOC_TOPIC);
let data = serde_json::to_vec(ioc)
.map_err(|e| anyhow::anyhow!("Failed to serialize IoC: {e}"))?;
if data.len() > hivemind::MAX_MESSAGE_SIZE {
anyhow::bail!("IoC message exceeds max size");
}
let msg_id = swarm
.behaviour_mut()
.gossipsub
.publish(topic, data)
.map_err(|e| anyhow::anyhow!("Failed to publish IoC: {e}"))?;
debug!(?msg_id, "Published IoC");
Ok(msg_id)
}
/// Publish a JA4 fingerprint to the JA4 topic.
pub fn publish_ja4(
swarm: &mut Swarm<HiveMindBehaviour>,
ja4_fingerprint: &str,
src_ip: u32,
) -> anyhow::Result<gossipsub::MessageId> {
let topic = gossipsub::IdentTopic::new(hivemind::topics::JA4_TOPIC);
let payload = serde_json::json!({
"ja4": ja4_fingerprint,
"src_ip": src_ip,
"timestamp": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
});
let data = serde_json::to_vec(&payload)
.map_err(|e| anyhow::anyhow!("Failed to serialize JA4: {e}"))?;
let msg_id = swarm
.behaviour_mut()
.gossipsub
.publish(topic, data)
.map_err(|e| anyhow::anyhow!("Failed to publish JA4: {e}"))?;
debug!(?msg_id, ja4 = ja4_fingerprint, "Published JA4 fingerprint");
Ok(msg_id)
}
/// Publish a raw proof envelope to the A2A violations topic.
///
/// Called when proof data is ingested from the local blackwall-enterprise daemon (optional)
/// via the TCP proof ingestion socket. The data is published verbatim —
/// the hivemind node acts as a relay, not a parser.
pub fn publish_proof_envelope(
swarm: &mut Swarm<HiveMindBehaviour>,
data: &[u8],
) -> anyhow::Result<gossipsub::MessageId> {
let topic = gossipsub::IdentTopic::new(hivemind::topics::A2A_VIOLATIONS_TOPIC);
// SECURITY: Enforce maximum message size
if data.len() > hivemind::MAX_MESSAGE_SIZE {
anyhow::bail!(
"proof envelope exceeds max message size ({} > {})",
data.len(),
hivemind::MAX_MESSAGE_SIZE
);
}
let msg_id = swarm
.behaviour_mut()
.gossipsub
.publish(topic, data.to_vec())
.map_err(|e| anyhow::anyhow!("Failed to publish proof envelope: {e}"))?;
debug!(?msg_id, bytes = data.len(), "Published proof envelope to A2A violations topic");
Ok(msg_id)
}
/// Configure GossipSub topic scoring to penalize invalid messages.
pub fn configure_topic_scoring(swarm: &mut Swarm<HiveMindBehaviour>) {
let ioc_topic = gossipsub::IdentTopic::new(hivemind::topics::IOC_TOPIC);
let params = gossipsub::TopicScoreParams {
topic_weight: 1.0,
time_in_mesh_weight: 0.5,
time_in_mesh_quantum: std::time::Duration::from_secs(1),
first_message_deliveries_weight: 1.0,
first_message_deliveries_cap: 20.0,
// Heavy penalty for invalid/poisoned IoC messages
invalid_message_deliveries_weight: -100.0,
invalid_message_deliveries_decay: 0.1,
..Default::default()
};
swarm
.behaviour_mut()
.gossipsub
.set_topic_params(ioc_topic, params)
.ok(); // set_topic_params can fail if topic not subscribed yet
}
/// Handle an incoming GossipSub message.
///
/// Returns the deserialized IoC if valid, or None if the message is
/// malformed or fails basic validation.
pub fn handle_gossip_message(
propagation_source: PeerId,
message: gossipsub::Message,
) -> Option<IoC> {
let topic = message.topic.as_str();
match topic {
t if t == hivemind::topics::IOC_TOPIC => {
match serde_json::from_slice::<IoC>(&message.data) {
Ok(ioc) => {
info!(
%propagation_source,
ioc_type = ioc.ioc_type,
severity = ioc.severity,
"Received IoC from peer"
);
// SECURITY: Single-peer IoC — track but don't trust yet
// Cross-validation happens in consensus module (Phase 1)
Some(ioc)
}
Err(e) => {
warn!(
%propagation_source,
error = %e,
"Failed to deserialize IoC message — potential poisoning"
);
None
}
}
}
t if t == hivemind::topics::JA4_TOPIC => {
debug!(
%propagation_source,
data_len = message.data.len(),
"Received JA4 fingerprint from peer"
);
None // JA4 messages are informational, not IoC
}
t if t == hivemind::topics::HEARTBEAT_TOPIC => {
debug!(%propagation_source, "Peer heartbeat received");
None
}
t if t == hivemind::topics::A2A_VIOLATIONS_TOPIC => {
info!(
%propagation_source,
bytes = message.data.len(),
"Received A2A violation proof from peer"
);
// A2A proofs are informational — logged and counted by metrics.
// Future: store for local policy enforcement or cross-validation.
None
}
_ => {
warn!(
%propagation_source,
topic,
"Unknown GossipSub topic — ignoring"
);
None
}
}
}

141
hivemind/src/identity.rs Executable file
View file

@ -0,0 +1,141 @@
//! Persistent node identity — load or generate Ed25519 keypair.
//!
//! On first launch the keypair is generated and saved to disk.
//! Subsequent launches reuse the same identity so the PeerId is
//! stable across restarts and reputation persists in the mesh.
//!
//! SECURITY: The key file is created with mode 0600 (owner-only).
//! If the permissions are wrong at load time we refuse to start
//! rather than risk using a compromised key.
use anyhow::Context;
use libp2p::identity::Keypair;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::info;
#[cfg(not(unix))]
use tracing::warn;
/// Default directory for Blackwall identity and state.
const DEFAULT_DATA_DIR: &str = ".blackwall";
/// Filename for the Ed25519 secret key (PKCS#8 DER).
const IDENTITY_FILENAME: &str = "identity.key";
/// Expected Unix permissions (read/write owner only).
#[cfg(unix)]
const REQUIRED_MODE: u32 = 0o600;
/// Resolve the identity key path.
///
/// Priority:
/// 1. Explicit path from config (`identity_key_path`)
/// 2. `/etc/blackwall/identity.key` when running as root
/// 3. `~/.blackwall/identity.key` otherwise
pub fn resolve_key_path(explicit: Option<&str>) -> anyhow::Result<PathBuf> {
if let Some(p) = explicit {
return Ok(PathBuf::from(p));
}
// Running as root → system-wide path
#[cfg(unix)]
if nix::unistd::geteuid().is_root() {
return Ok(PathBuf::from("/etc/blackwall").join(IDENTITY_FILENAME));
}
// Regular user → home dir
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.context("Cannot determine home directory (neither HOME nor USERPROFILE set)")?;
Ok(PathBuf::from(home).join(DEFAULT_DATA_DIR).join(IDENTITY_FILENAME))
}
/// Load an existing keypair or generate a new one and persist it.
pub fn load_or_generate(key_path: &Path) -> anyhow::Result<Keypair> {
if key_path.exists() {
load_keypair(key_path)
} else {
generate_and_save(key_path)
}
}
/// Load keypair from disk, verifying file permissions first.
fn load_keypair(path: &Path) -> anyhow::Result<Keypair> {
verify_permissions(path)?;
let der = fs::read(path)
.with_context(|| format!("Failed to read identity key: {}", path.display()))?;
let keypair = Keypair::from_protobuf_encoding(&der)
.context("Failed to decode identity key (corrupt or wrong format?)")?;
info!(path = %path.display(), "Loaded persistent identity");
Ok(keypair)
}
/// Generate a fresh Ed25519 keypair, create parent dirs, save with 0600.
fn generate_and_save(path: &Path) -> anyhow::Result<Keypair> {
let keypair = Keypair::generate_ed25519();
// Create parent directory if missing
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Cannot create directory: {}", parent.display()))?;
// SECURITY: restrict directory to owner-only on Unix
#[cfg(unix)]
set_permissions(parent, 0o700)?;
}
// Serialize to protobuf (libp2p's canonical format)
let encoded = keypair
.to_protobuf_encoding()
.context("Failed to encode keypair")?;
fs::write(path, &encoded)
.with_context(|| format!("Failed to write identity key: {}", path.display()))?;
// SECURITY: set 0600 immediately after write
#[cfg(unix)]
set_permissions(path, REQUIRED_MODE)?;
info!(
path = %path.display(),
"Generated new persistent identity (saved to disk)"
);
Ok(keypair)
}
/// Verify that the key file has strict permissions (Unix only).
#[cfg(unix)]
fn verify_permissions(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(path)
.with_context(|| format!("Cannot stat identity key: {}", path.display()))?;
let mode = meta.permissions().mode() & 0o777;
if mode != REQUIRED_MODE {
anyhow::bail!(
"Identity key {} has insecure permissions {:04o} (expected {:04o}). \
Fix with: chmod 600 {}",
path.display(),
mode,
REQUIRED_MODE,
path.display(),
);
}
Ok(())
}
/// No-op permission check on non-Unix platforms.
#[cfg(not(unix))]
fn verify_permissions(_path: &Path) -> anyhow::Result<()> {
warn!("File permission check skipped (non-Unix platform)");
Ok(())
}
/// Set file/dir permissions (Unix only).
#[cfg(unix)]
fn set_permissions(path: &Path, mode: u32) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode);
fs::set_permissions(path, perms)
.with_context(|| format!("Cannot set permissions {:04o} on {}", mode, path.display()))?;
Ok(())
}

24
hivemind/src/lib.rs Executable file
View file

@ -0,0 +1,24 @@
//! HiveMind P2P Threat Intelligence Mesh.
//!
//! Library crate exposing the core P2P modules for the HiveMind daemon.
//! Each module handles a specific aspect of the P2P networking stack:
//!
//! - `transport` — libp2p Swarm with Noise+QUIC, composite NetworkBehaviour
//! - `dht` — Kademlia DHT for structured peer routing and IoC storage
//! - `gossip` — GossipSub for epidemic IoC broadcast
//! - `bootstrap` — Initial peer discovery (hardcoded nodes + mDNS)
//! - `config` — TOML-based configuration
pub mod bootstrap;
pub mod config;
pub mod consensus;
pub mod crypto;
pub mod dht;
pub mod gossip;
pub mod identity;
pub mod metrics_bridge;
pub mod ml;
pub mod reputation;
pub mod sybil_guard;
pub mod transport;
pub mod zkp;

871
hivemind/src/main.rs Executable file
View file

@ -0,0 +1,871 @@
/// HiveMind — P2P Threat Intelligence Mesh daemon.
///
/// Entry point for the HiveMind node. Builds the libp2p swarm,
/// subscribes to GossipSub topics, connects to bootstrap nodes,
/// and runs the event loop with consensus + reputation tracking.
use anyhow::Context;
use libp2p::{futures::StreamExt, swarm::SwarmEvent};
use std::path::PathBuf;
use tracing::{info, warn};
use hivemind::bootstrap;
use hivemind::config::{self, HiveMindConfig, NodeMode};
use hivemind::consensus::{ConsensusEngine, ConsensusResult};
use hivemind::crypto::fhe::FheContext;
use hivemind::dht;
use hivemind::gossip;
use hivemind::identity;
use hivemind::metrics_bridge::{self, SharedP2pMetrics, P2pMetrics};
use hivemind::ml::aggregator::FedAvgAggregator;
use hivemind::ml::defense::{GradientDefense, GradientVerdict};
use hivemind::ml::gradient_share;
use hivemind::ml::local_model::LocalModel;
use hivemind::reputation::ReputationStore;
use hivemind::sybil_guard::SybilGuard;
use hivemind::transport;
use hivemind::zkp;
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
// Initialize structured logging
tracing_subscriber::fmt::init();
let config = load_or_default_config()?;
// --- Persistent identity ---
let key_path = identity::resolve_key_path(config.identity_key_path.as_deref())
.context("Cannot resolve identity key path")?;
let keypair = identity::load_or_generate(&key_path)
.context("Cannot load/generate identity keypair")?;
let mut swarm = transport::build_swarm(&config, keypair)
.context("Failed to build HiveMind swarm")?;
let local_peer_id = *swarm.local_peer_id();
info!(%local_peer_id, "HiveMind node starting");
// Start listening
transport::start_listening(&mut swarm, &config)?;
// Subscribe to GossipSub topics
gossip::subscribe_all(&mut swarm)?;
// Configure topic scoring (anti-poisoning)
gossip::configure_topic_scoring(&mut swarm);
// Connect to bootstrap nodes
let seed_peer_ids = bootstrap::connect_bootstrap_nodes(&mut swarm, &config, &local_peer_id)?;
// --- P2P metrics bridge (pushes live stats to hivemind-api) ---
let p2p_metrics: SharedP2pMetrics = std::sync::Arc::new(P2pMetrics::default());
// Metrics push interval (5 seconds) — pushes P2P stats to hivemind-api
let mut metrics_interval = tokio::time::interval(
std::time::Duration::from_secs(5),
);
metrics_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
info!(mode = ?config.mode, "HiveMind event loop starting");
match config.mode {
NodeMode::Bootstrap => run_bootstrap_loop(&mut swarm, &p2p_metrics, metrics_interval).await,
NodeMode::Full => run_full_loop(&mut swarm, &local_peer_id, &seed_peer_ids, &p2p_metrics, metrics_interval).await,
}
}
/// Lightweight bootstrap event loop — only Kademlia routing + GossipSub
/// message forwarding + metrics push. No reputation, consensus, FL, or ZKP.
///
/// ARCH: Bootstrap nodes will also serve as Circuit Relay v2 destinations
/// once NAT traversal is implemented (AutoNAT + Relay).
async fn run_bootstrap_loop(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
p2p_metrics: &SharedP2pMetrics,
mut metrics_interval: tokio::time::Interval,
) -> anyhow::Result<()> {
info!("Running in BOOTSTRAP mode — relay only (no DPI/AI/FL)");
loop {
tokio::select! {
event = swarm.select_next_some() => {
handle_bootstrap_event(swarm, event, p2p_metrics);
}
_ = metrics_interval.tick() => {
metrics_bridge::push_p2p_metrics(p2p_metrics).await;
}
_ = tokio::signal::ctrl_c() => {
info!("Received SIGINT — shutting down bootstrap node");
break;
}
}
}
info!("HiveMind bootstrap node shut down gracefully");
Ok(())
}
/// Full event loop — all modules active.
async fn run_full_loop(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
local_peer_id: &libp2p::PeerId,
seed_peer_ids: &[libp2p::PeerId],
p2p_metrics: &SharedP2pMetrics,
mut metrics_interval: tokio::time::Interval,
) -> anyhow::Result<()> {
// --- Phase 1: Anti-Poisoning modules ---
let mut reputation = ReputationStore::new();
let mut consensus = ConsensusEngine::new();
let sybil_guard = SybilGuard::new();
// Register bootstrap nodes as seed peers with elevated stake so their
// IoC reports are trusted immediately. Without this, INITIAL_STAKE < MIN_TRUSTED
// means no peer can ever reach consensus.
for peer_id in seed_peer_ids {
let pubkey = peer_id_to_pubkey(peer_id);
reputation.register_seed_peer(&pubkey);
}
// Also register self as seed peer — our own IoC submissions should count
let local_pubkey_seed = peer_id_to_pubkey(local_peer_id);
reputation.register_seed_peer(&local_pubkey_seed);
info!(
seed_peers = seed_peer_ids.len() + 1,
"Phase 1 security modules initialized (reputation, consensus, sybil_guard)"
);
// --- Phase 2: Federated Learning modules ---
let mut local_model = LocalModel::new(0.01);
let fhe_ctx = FheContext::new();
let mut aggregator = FedAvgAggregator::new();
let mut gradient_defense = GradientDefense::new();
// Extract local node pubkey for gradient messages
let local_pubkey = peer_id_to_pubkey(local_peer_id);
info!(
model_params = local_model.param_count(),
fhe_stub = fhe_ctx.is_stub(),
"Phase 2 federated learning modules initialized"
);
// Periodic eviction interval (5 minutes)
let mut eviction_interval = tokio::time::interval(
std::time::Duration::from_secs(common::hivemind::CONSENSUS_TIMEOUT_SECS),
);
eviction_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
// Federated learning round interval (60 seconds)
let mut fl_round_interval = tokio::time::interval(
std::time::Duration::from_secs(common::hivemind::FL_ROUND_INTERVAL_SECS),
);
fl_round_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
info!("Full-mode event loop starting");
// --- Proof ingestion socket (enterprise module → hivemind) ---
let proof_addr = format!("127.0.0.1:{}", common::hivemind::PROOF_INGEST_PORT);
let proof_listener = tokio::net::TcpListener::bind(&proof_addr)
.await
.context("failed to bind proof ingestion listener")?;
info!(addr = %proof_addr, "proof ingestion listener ready");
// --- IoC injection socket (for testing/integration) ---
let ioc_addr = format!("127.0.0.1:{}", common::hivemind::IOC_INJECT_PORT);
let ioc_listener = tokio::net::TcpListener::bind(&ioc_addr)
.await
.context("failed to bind IoC injection listener")?;
info!(addr = %ioc_addr, "IoC injection listener ready");
// Main event loop
loop {
tokio::select! {
event = swarm.select_next_some() => {
handle_swarm_event(
swarm,
event,
local_peer_id,
&mut reputation,
&mut consensus,
&fhe_ctx,
&mut aggregator,
&mut gradient_defense,
&mut local_model,
p2p_metrics,
);
}
result = proof_listener.accept() => {
if let Ok((stream, addr)) = result {
tracing::debug!(%addr, "proof ingestion connection");
ingest_proof_envelope(swarm, stream).await;
}
}
result = ioc_listener.accept() => {
if let Ok((stream, addr)) = result {
tracing::debug!(%addr, "IoC injection connection");
ingest_and_publish_ioc(
swarm,
stream,
&local_pubkey,
&mut reputation,
&mut consensus,
).await;
}
}
_ = eviction_interval.tick() => {
consensus.evict_expired();
}
_ = fl_round_interval.tick() => {
// Federated Learning round: compute and broadcast gradients
handle_fl_round(
swarm,
&mut local_model,
&fhe_ctx,
&mut aggregator,
&local_pubkey,
);
}
_ = metrics_interval.tick() => {
metrics_bridge::push_p2p_metrics(p2p_metrics).await;
}
_ = tokio::signal::ctrl_c() => {
info!("Received SIGINT — shutting down HiveMind");
break;
}
}
}
// Log accepted IoCs before shutting down
let final_accepted = consensus.drain_accepted();
if !final_accepted.is_empty() {
info!(
count = final_accepted.len(),
"Draining accepted IoCs at shutdown"
);
}
// Suppress unused variable warnings until sybil_guard is wired
// into the peer registration handshake protocol (Phase 2).
let _ = &sybil_guard;
info!("HiveMind shut down gracefully");
Ok(())
}
/// Read a length-prefixed proof envelope from a TCP connection and
/// publish it to GossipSub.
///
/// Wire format: `[4-byte big-endian length][JSON payload]`.
async fn ingest_proof_envelope(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
mut stream: tokio::net::TcpStream,
) {
use tokio::io::AsyncReadExt;
// Read 4-byte length prefix
let mut len_buf = [0u8; 4];
if let Err(e) = stream.read_exact(&mut len_buf).await {
warn!(error = %e, "proof ingestion: failed to read length prefix");
return;
}
let len = u32::from_be_bytes(len_buf) as usize;
if len == 0 || len > common::hivemind::MAX_MESSAGE_SIZE {
warn!(len, "proof ingestion: invalid message length");
return;
}
// Read payload
let mut buf = vec![0u8; len];
if let Err(e) = stream.read_exact(&mut buf).await {
warn!(error = %e, len, "proof ingestion: failed to read payload");
return;
}
// Publish to GossipSub
match gossip::publish_proof_envelope(swarm, &buf) {
Ok(msg_id) => {
info!(?msg_id, bytes = len, "published ingested proof to mesh");
}
Err(e) => {
warn!(error = %e, "failed to publish ingested proof to GossipSub");
}
}
}
/// Read a length-prefixed IoC JSON from a TCP connection, publish it
/// to GossipSub IOC topic, and submit to local consensus.
///
/// Wire format: `[4-byte big-endian length][JSON IoC payload]`.
async fn ingest_and_publish_ioc(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
mut stream: tokio::net::TcpStream,
local_pubkey: &[u8; 32],
reputation: &mut ReputationStore,
consensus: &mut ConsensusEngine,
) {
use common::hivemind::IoC;
use tokio::io::AsyncReadExt;
let mut len_buf = [0u8; 4];
if let Err(e) = stream.read_exact(&mut len_buf).await {
warn!(error = %e, "IoC inject: failed to read length prefix");
return;
}
let len = u32::from_be_bytes(len_buf) as usize;
if len == 0 || len > common::hivemind::MAX_MESSAGE_SIZE {
warn!(len, "IoC inject: invalid message length");
return;
}
let mut buf = vec![0u8; len];
if let Err(e) = stream.read_exact(&mut buf).await {
warn!(error = %e, len, "IoC inject: failed to read payload");
return;
}
let ioc: IoC = match serde_json::from_slice(&buf) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "IoC inject: invalid JSON");
return;
}
};
// 1. Publish to GossipSub so other peers receive it
match gossip::publish_ioc(swarm, &ioc) {
Ok(msg_id) => {
info!(?msg_id, ip = ioc.ip, "published injected IoC to mesh");
}
Err(e) => {
warn!(error = %e, "failed to publish injected IoC to GossipSub");
}
}
// 2. Submit to local consensus with our own pubkey
match consensus.submit_ioc(&ioc, local_pubkey) {
ConsensusResult::Accepted(count) => {
info!(count, ip = ioc.ip, "injected IoC reached consensus");
reputation.record_accurate_report(local_pubkey);
if ioc.ip != 0 {
if let Err(e) = append_accepted_ioc(ioc.ip, ioc.severity, count as u8) {
warn!("failed to persist accepted IoC: {}", e);
}
}
}
ConsensusResult::Pending(count) => {
info!(count, ip = ioc.ip, "injected IoC pending cross-validation");
}
ConsensusResult::DuplicatePeer => {
warn!(ip = ioc.ip, "injected IoC: duplicate peer submission");
}
ConsensusResult::Expired => {
info!(ip = ioc.ip, "injected IoC: pending entry expired");
}
}
}
/// Load config from `hivemind.toml` in the current directory, or use defaults.
fn load_or_default_config() -> anyhow::Result<HiveMindConfig> {
let config_path = PathBuf::from("hivemind.toml");
if config_path.exists() {
let cfg = config::load_config(&config_path)
.context("Failed to load hivemind.toml")?;
info!(?config_path, "Configuration loaded");
Ok(cfg)
} else {
info!("No hivemind.toml found — using default configuration");
Ok(HiveMindConfig {
mode: Default::default(),
identity_key_path: None,
network: Default::default(),
bootstrap: Default::default(),
})
}
}
/// Lightweight event handler for bootstrap mode.
///
/// Only processes Kademlia routing, GossipSub forwarding (no content
/// inspection), mDNS discovery, Identify, and connection lifecycle.
/// GossipSub messages are automatically forwarded by the protocol — we
/// just need to update metrics and log connection events.
fn handle_bootstrap_event(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
event: SwarmEvent<transport::HiveMindBehaviourEvent>,
p2p_metrics: &SharedP2pMetrics,
) {
match event {
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Kademlia(kad_event)) => {
dht::handle_kad_event(kad_event);
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Message { message_id, propagation_source, .. },
)) => {
// Bootstrap nodes only forward — no content inspection
p2p_metrics.messages_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tracing::debug!(?message_id, %propagation_source, "Relayed GossipSub message");
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Subscribed { peer_id, topic },
)) => {
info!(%peer_id, %topic, "Peer subscribed to topic");
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Unsubscribed { peer_id, topic },
)) => {
info!(%peer_id, %topic, "Peer unsubscribed from topic");
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(_)) => {}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Mdns(
libp2p::mdns::Event::Discovered(peers),
)) => {
let local = *swarm.local_peer_id();
bootstrap::handle_mdns_discovered(swarm, peers, &local);
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Mdns(
libp2p::mdns::Event::Expired(peers),
)) => {
bootstrap::handle_mdns_expired(swarm, peers);
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Identify(
libp2p::identify::Event::Received { peer_id, info, .. },
)) => {
for addr in info.listen_addrs {
if bootstrap::is_routable_addr(&addr) {
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
}
}
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Identify(_)) => {}
SwarmEvent::NewListenAddr { address, .. } => {
info!(%address, "New listen address");
}
SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } => {
info!(%peer_id, ?endpoint, "Connection established");
p2p_metrics.peer_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
SwarmEvent::ConnectionClosed { peer_id, cause, .. } => {
info!(%peer_id, cause = ?cause, "Connection closed");
let prev = p2p_metrics.peer_count.load(std::sync::atomic::Ordering::Relaxed);
if prev > 0 {
p2p_metrics.peer_count.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
SwarmEvent::IncomingConnectionError { local_addr, error, .. } => {
warn!(%local_addr, %error, "Incoming connection error");
}
SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
warn!(peer = ?peer_id, %error, "Outgoing connection error");
}
_ => {}
}
}
/// Dispatch swarm events to the appropriate handler module.
#[allow(clippy::too_many_arguments)]
fn handle_swarm_event(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
event: SwarmEvent<transport::HiveMindBehaviourEvent>,
local_peer_id: &libp2p::PeerId,
reputation: &mut ReputationStore,
consensus: &mut ConsensusEngine,
fhe_ctx: &FheContext,
aggregator: &mut FedAvgAggregator,
gradient_defense: &mut GradientDefense,
local_model: &mut LocalModel,
p2p_metrics: &SharedP2pMetrics,
) {
match event {
// --- Kademlia events ---
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Kademlia(kad_event)) => {
dht::handle_kad_event(kad_event);
}
// --- GossipSub events ---
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Message {
propagation_source,
message,
message_id,
..
},
)) => {
info!(?message_id, %propagation_source, "GossipSub message received");
p2p_metrics.messages_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// Phase 2: Route gradient messages to FL handler
if message.topic.as_str() == common::hivemind::topics::GRADIENT_TOPIC {
if let Some(update) = gradient_share::handle_gradient_message(
propagation_source,
&message.data,
) {
handle_gradient_update(
update,
&propagation_source,
fhe_ctx,
aggregator,
gradient_defense,
local_model,
reputation,
);
}
return;
}
if let Some(ioc) = gossip::handle_gossip_message(
propagation_source,
message.clone(),
) {
// Phase 1: Extract reporter pubkey from original publisher,
// NOT propagation_source (which is the forwarding peer).
// GossipSub MessageAuthenticity::Signed embeds the author.
let author = message.source.unwrap_or(propagation_source);
let reporter_pubkey = peer_id_to_pubkey(&author);
// Register peer if new (idempotent)
reputation.register_peer(&reporter_pubkey);
// Verify ZKP proof if present
if !ioc.zkp_proof.is_empty() {
// Deserialize and verify the proof attached to the IoC
if let Ok(proof) = serde_json::from_slice::<
common::hivemind::ThreatProof,
>(&ioc.zkp_proof) {
let result = zkp::verifier::verify_threat(&proof, None);
match result {
zkp::verifier::VerifyResult::Valid
| zkp::verifier::VerifyResult::ValidStub => {
info!(%propagation_source, "ZKP proof verified");
}
other => {
warn!(
%propagation_source,
result = ?other,
"ZKP proof verification failed — untrusted IoC"
);
}
}
}
}
// Submit to consensus — only trusted peers count
if reputation.is_trusted(&reporter_pubkey) {
match consensus.submit_ioc(&ioc, &reporter_pubkey) {
ConsensusResult::Accepted(count) => {
info!(
count,
ioc_type = ioc.ioc_type,
"IoC reached consensus — adding to threat database"
);
reputation.record_accurate_report(&reporter_pubkey);
// Persist accepted IoC IP for blackwall daemon ingestion
if ioc.ip != 0 {
if let Err(e) = append_accepted_ioc(
ioc.ip,
ioc.severity,
count as u8,
) {
warn!("failed to persist accepted IoC: {}", e);
}
}
}
ConsensusResult::Pending(count) => {
info!(
count,
threshold = common::hivemind::CROSS_VALIDATION_THRESHOLD,
"IoC pending cross-validation"
);
}
ConsensusResult::DuplicatePeer => {
warn!(
%propagation_source,
"Duplicate IoC confirmation — ignoring"
);
}
ConsensusResult::Expired => {
info!("Pending IoC expired before consensus");
}
}
} else {
warn!(
%propagation_source,
stake = reputation.get_stake(&reporter_pubkey),
"IoC from untrusted peer — ignoring"
);
}
}
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Subscribed { peer_id, topic },
)) => {
info!(%peer_id, %topic, "Peer subscribed to topic");
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Unsubscribed { peer_id, topic },
)) => {
info!(%peer_id, %topic, "Peer unsubscribed from topic");
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Gossipsub(_)) => {}
// --- mDNS events ---
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Mdns(
libp2p::mdns::Event::Discovered(peers),
)) => {
bootstrap::handle_mdns_discovered(
swarm,
peers,
local_peer_id,
);
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Mdns(
libp2p::mdns::Event::Expired(peers),
)) => {
bootstrap::handle_mdns_expired(swarm, peers);
}
// --- Identify events ---
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Identify(
libp2p::identify::Event::Received { peer_id, info, .. },
)) => {
info!(
%peer_id,
protocol = %info.protocol_version,
agent = %info.agent_version,
"Identify: received peer info"
);
// Add identified addresses to Kademlia
for addr in info.listen_addrs {
if bootstrap::is_routable_addr(&addr) {
swarm
.behaviour_mut()
.kademlia
.add_address(&peer_id, addr);
}
}
}
SwarmEvent::Behaviour(transport::HiveMindBehaviourEvent::Identify(_)) => {}
// --- Connection lifecycle ---
SwarmEvent::NewListenAddr { address, .. } => {
info!(%address, "New listen address");
}
SwarmEvent::ConnectionEstablished {
peer_id, endpoint, ..
} => {
info!(%peer_id, ?endpoint, "Connection established");
p2p_metrics.peer_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
SwarmEvent::ConnectionClosed {
peer_id, cause, ..
} => {
info!(
%peer_id,
cause = ?cause,
"Connection closed"
);
// Saturating decrement
let prev = p2p_metrics.peer_count.load(std::sync::atomic::Ordering::Relaxed);
if prev > 0 {
p2p_metrics.peer_count.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
SwarmEvent::IncomingConnectionError {
local_addr, error, ..
} => {
warn!(%local_addr, %error, "Incoming connection error");
}
SwarmEvent::OutgoingConnectionError {
peer_id, error, ..
} => {
warn!(peer = ?peer_id, %error, "Outgoing connection error");
}
_ => {}
}
}
/// Extract a 32-byte public key representation from a PeerId.
///
/// PeerId is a multihash of the public key. We use the raw bytes
/// truncated/padded to 32 bytes as a deterministic peer identifier
/// for the reputation system.
fn peer_id_to_pubkey(peer_id: &libp2p::PeerId) -> [u8; 32] {
let bytes = peer_id.to_bytes();
let mut pubkey = [0u8; 32];
let len = bytes.len().min(32);
pubkey[..len].copy_from_slice(&bytes[..len]);
pubkey
}
/// Handle an incoming gradient update from a peer.
///
/// Decrypts the FHE payload, runs defense checks, and submits to
/// the aggregator if safe. When enough contributions arrive, triggers
/// federated aggregation and model update.
fn handle_gradient_update(
update: common::hivemind::GradientUpdate,
propagation_source: &libp2p::PeerId,
fhe_ctx: &FheContext,
aggregator: &mut FedAvgAggregator,
gradient_defense: &mut GradientDefense,
local_model: &mut LocalModel,
reputation: &mut ReputationStore,
) {
// Decrypt gradients from FHE ciphertext
let gradients = match fhe_ctx.decrypt_gradients(&update.encrypted_gradients) {
Ok(g) => g,
Err(e) => {
warn!(
%propagation_source,
error = %e,
"Failed to decrypt gradient payload"
);
return;
}
};
// Run defense checks on decrypted gradients
match gradient_defense.check(&gradients) {
GradientVerdict::Safe => {}
verdict => {
warn!(
%propagation_source,
?verdict,
"Gradient rejected by defense module"
);
// Slash reputation for bad gradient contributions
let pubkey = peer_id_to_pubkey(propagation_source);
reputation.record_false_report(&pubkey);
return;
}
}
// Submit to aggregator
match aggregator.submit_gradients(
&update.peer_pubkey,
update.round_id,
gradients,
) {
Ok(count) => {
info!(
count,
round = update.round_id,
"Gradient contribution accepted"
);
// If enough peers contributed, aggregate and update model
if aggregator.ready_to_aggregate() {
match aggregator.aggregate() {
Ok(agg_gradients) => {
local_model.apply_gradients(&agg_gradients);
info!(
round = aggregator.current_round(),
participants = count,
"Federated model updated via FedAvg"
);
aggregator.advance_round();
}
Err(e) => {
warn!(error = %e, "Aggregation failed");
}
}
}
}
Err(e) => {
warn!(
%propagation_source,
error = %e,
"Gradient contribution rejected"
);
}
}
}
/// Periodic federated learning round handler.
///
/// Computes local gradients on a synthetic training sample, encrypts
/// them via FHE, and broadcasts to the gradient topic.
fn handle_fl_round(
swarm: &mut libp2p::Swarm<transport::HiveMindBehaviour>,
local_model: &mut LocalModel,
fhe_ctx: &FheContext,
aggregator: &mut FedAvgAggregator,
local_pubkey: &[u8; 32],
) {
let round_id = aggregator.current_round();
// ARCH: In production, training data comes from local eBPF telemetry.
// For now, use a synthetic "benign traffic" sample as a training signal.
let synthetic_input = vec![0.5_f32; common::hivemind::FL_FEATURE_DIM];
let synthetic_target = 0.0; // benign
// Forward and backward pass
local_model.forward(&synthetic_input);
let gradients = local_model.backward(synthetic_target);
// Encrypt gradients before transmission
let encrypted = match fhe_ctx.encrypt_gradients(&gradients) {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "Failed to encrypt gradients — skipping FL round");
return;
}
};
// Publish to the gradient topic
match gradient_share::publish_gradients(swarm, local_pubkey, round_id, encrypted) {
Ok(msg_id) => {
info!(
?msg_id,
round_id,
"Local gradients broadcasted for FL round"
);
}
Err(e) => {
// Expected to fail when no peers are connected — not an error
warn!(error = %e, "Could not publish gradients (no peers?)");
}
}
}
/// Append an accepted IoC IP to the shared file for blackwall daemon ingestion.
///
/// Format: one JSON object per line with ip, severity, confidence, and
/// block duration. The blackwall daemon polls this file, reads all lines,
/// adds them to the BLOCKLIST with the prescribed TTL, and removes the file.
/// Directory is created on first write if it doesn't exist.
fn append_accepted_ioc(ip: u32, severity: u8, confirmations: u8) -> std::io::Result<()> {
use std::io::Write;
let dir = PathBuf::from("/run/blackwall");
if !dir.exists() {
info!(dir = %dir.display(), "creating /run/blackwall directory");
std::fs::create_dir_all(&dir)?;
}
let path = dir.join("hivemind_accepted_iocs");
// Block duration scales with severity: high severity → longer block
let duration_secs: u32 = match severity {
0..=2 => 1800, // low: 30 min
3..=5 => 3600, // medium: 1 hour
6..=8 => 7200, // high: 2 hours
_ => 14400, // critical: 4 hours
};
info!(
ip,
severity,
confirmations,
duration_secs,
path = %path.display(),
"persisting accepted IoC to file"
);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
writeln!(
file,
r#"{{"ip":{},"severity":{},"confirmations":{},"duration_secs":{}}}"#,
ip, severity, confirmations, duration_secs,
)?;
info!("IoC persisted successfully");
Ok(())
}

Some files were not shown because too many files have changed in this diff Show more