mirror of
https://github.com/xzcrpw/blackwall.git
synced 2026-04-24 11:56:21 +02:00
v2.0.0: adaptive eBPF firewall with AI honeypot and P2P threat mesh
This commit is contained in:
commit
37c6bbf5a1
133 changed files with 28073 additions and 0 deletions
17
hivemind-dashboard/Cargo.toml
Executable file
17
hivemind-dashboard/Cargo.toml
Executable 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
345
hivemind-dashboard/src/app.rs
Executable 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"));
|
||||
}
|
||||
}
|
||||
150
hivemind-dashboard/src/collector.rs
Executable file
150
hivemind-dashboard/src/collector.rs
Executable 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
76
hivemind-dashboard/src/main.rs
Executable 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue