Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ __pycache__/
# Rust build output
capture-sidecar/target/
iot-sidecar/target/

# GeoIP city DB is large (131MB) — staged at build time, not committed
capture-sidecar/geoip/dbip-city-lite.mmdb
22 changes: 22 additions & 0 deletions capture-sidecar/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions capture-sidecar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }

# Event / capture ids.
uuid = { version = "1", features = ["v4"] }
maxminddb = "0.24"
Binary file added capture-sidecar/geoip/dbip-asn-lite.mmdb
Binary file not shown.
100 changes: 100 additions & 0 deletions capture-sidecar/src/bpf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Raw-capture capability: detect whether we can actually open a BPF device,
//! and (macOS) install the one-time ChmodBPF privileged helper so dumpcap/tshark
//! can capture as a normal user — the same mechanism Wireshark's installer uses.
//! This is what turns the capture button from a paper tiger into a real feature.

/// Can we actually capture right now? On macOS this means a /dev/bpf* device is
/// openable by us (true only after ChmodBPF + group membership are in place).
/// On other platforms we're optimistic — a failed start reports the real error.
pub fn can_capture() -> bool {
#[cfg(target_os = "macos")]
{
for i in 0..8 {
if std::fs::OpenOptions::new()
.read(true)
.open(format!("/dev/bpf{i}"))
.is_ok()
{
return true;
}
}
false
}
#[cfg(not(target_os = "macos"))]
{
true
}
}

/// Install the one-time capture-permission helper. macOS: creates the
/// `access_bpf` group, adds the user, installs a launchd ChmodBPF daemon that
/// grants that group access to /dev/bpf* at boot, and runs it once — all behind
/// a single admin prompt. Returns human instructions on success, or a manual
/// fallback command on failure/cancel.
pub async fn enable() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
use tokio::process::Command;
let user = std::env::var("USER").unwrap_or_default();
let script = format!(
r#"#!/bin/sh
set -e
GROUP=access_bpf
if ! dscl . -read /Groups/$GROUP >/dev/null 2>&1; then dseditgroup -o create $GROUP; fi
dseditgroup -o edit -a "{user}" -t user $GROUP || true
SUPPORT="/Library/Application Support/BearBrowser/ChmodBPF"
mkdir -p "$SUPPORT"
cat > "$SUPPORT/ChmodBPF" <<'EOS'
#!/bin/sh
if ! dscl . -read /Groups/access_bpf >/dev/null 2>&1; then dseditgroup -o create access_bpf; fi
for dev in /dev/bpf*; do chgrp access_bpf "$dev" 2>/dev/null || true; chmod g+rw "$dev" 2>/dev/null || true; done
EOS
chmod +x "$SUPPORT/ChmodBPF"
PLIST=/Library/LaunchDaemons/ai.socioprophet.bearbrowser.ChmodBPF.plist
cat > "$PLIST" <<'EOP'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>ai.socioprophet.bearbrowser.ChmodBPF</string>
<key>RunAtLoad</key><true/>
<key>ProgramArguments</key><array><string>/Library/Application Support/BearBrowser/ChmodBPF/ChmodBPF</string></array>
</dict></plist>
EOP
launchctl load "$PLIST" 2>/dev/null || true
"$SUPPORT/ChmodBPF"
"#
);
let path = std::env::temp_dir().join("bb-chmodbpf-install.sh");
std::fs::write(&path, script).map_err(|e| e.to_string())?;
let osa = format!(
"do shell script \"/bin/sh {}\" with administrator privileges \
with prompt \"BearBrowser needs one-time permission to capture network packets.\"",
path.display()
);
let out = Command::new("osascript")
.arg("-e")
.arg(osa)
.output()
.await
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok("Capture enabled. Quit and reopen BearBrowser once so it inherits \
the new capture permission."
.to_string())
} else {
let err = String::from_utf8_lossy(&out.stderr);
Err(format!(
"Admin prompt not completed ({}). To enable manually, run in Terminal:\n sudo /bin/sh {}",
err.trim(),
path.display()
))
}
}
#[cfg(not(target_os = "macos"))]
{
Err("Automatic enable is macOS-only. On Linux, add yourself to the \
'wireshark' group and grant dumpcap CAP_NET_RAW \
(sudo dpkg-reconfigure wireshark-common; sudo usermod -aG wireshark $USER)."
.to_string())
}
}
80 changes: 80 additions & 0 deletions capture-sidecar/src/geo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! Local IP intelligence — geolocation + ASN/owner, resolved entirely against
//! bundled MaxMind-format databases (DB-IP City Lite + ASN Lite, CC-BY). NO
//! network calls: a privacy browser must never leak the IPs you're connected to
//! to a geo/OSINT API just to draw a map. External OSINT (WHOIS, reverse-IP)
//! is a separate, user-initiated, gated action — see server::whois.

use maxminddb::{geoip2, Reader};
use std::net::IpAddr;
use std::path::Path;

/// What we can say about a remote IP without touching the network.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct GeoInfo {
pub lat: Option<f64>,
pub lon: Option<f64>,
pub country: Option<String>,
#[serde(rename = "countryCode")]
pub country_code: Option<String>,
pub city: Option<String>,
pub asn: Option<u32>,
/// Autonomous-system organization — the "who owns the block" at routing level.
pub org: Option<String>,
}

pub struct Geo {
city: Option<Reader<Vec<u8>>>,
asn: Option<Reader<Vec<u8>>>,
}

impl Geo {
/// Load whatever DBs are present in `dir`. Missing DBs degrade gracefully —
/// the map simply shows fewer facts, never an error.
pub fn load(dir: &Path) -> Geo {
let city = Reader::open_readfile(dir.join("dbip-city-lite.mmdb")).ok();
let asn = Reader::open_readfile(dir.join("dbip-asn-lite.mmdb")).ok();
if city.is_none() {
tracing::info!("no city GeoIP DB in {} — map dots disabled", dir.display());
}
Geo { city, asn }
}

pub fn available(&self) -> bool {
self.city.is_some() || self.asn.is_some()
}

pub fn lookup(&self, ip: &str) -> GeoInfo {
let mut info = GeoInfo::default();
let addr: IpAddr = match ip.parse() {
Ok(a) => a,
Err(_) => return info,
};
if let Some(r) = &self.city {
if let Ok(c) = r.lookup::<geoip2::City>(addr) {
if let Some(loc) = c.location {
info.lat = loc.latitude;
info.lon = loc.longitude;
}
if let Some(country) = c.country {
info.country_code = country.iso_code.map(|s| s.to_string());
info.country = country
.names
.and_then(|n| n.get("en").map(|s| s.to_string()));
}
info.city = c
.city
.and_then(|ci| ci.names)
.and_then(|n| n.get("en").map(|s| s.to_string()));
}
}
if let Some(r) = &self.asn {
if let Ok(a) = r.lookup::<geoip2::Asn>(addr) {
info.asn = a.autonomous_system_number;
info.org = a
.autonomous_system_organization
.map(|s| s.to_string());
}
}
info
}
}
24 changes: 22 additions & 2 deletions capture-sidecar/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
//! `scripts/agent-control-bridge.py --surface capture` engine — this binary
//! reimplements NO policy and refuses to start if the bridge is absent.

mod bpf;
mod capture;
mod firewall;
mod gate;
mod geo;
mod model;
mod netmap;
mod netmon;
mod osint;
mod server;

use firewall::Firewall;
Expand Down Expand Up @@ -126,13 +130,29 @@ async fn main() -> anyhow::Result<()> {

std::fs::create_dir_all(&cfg.state_dir).ok();
let (events, _) = broadcast::channel(1024);
let firewall = Arc::new(Firewall::load(Some(cfg.state_dir.join("firewall.json"))));
let monitor = Arc::new(NetworkMonitor::new(2048));
let scope: netmon::SharedScope =
Arc::new(std::sync::RwLock::new(model::Scope::default()));
// Local IP-intelligence databases (geo + ASN). Dev: capture-sidecar/geoip;
// override with CAPTURE_SIDECAR_GEOIP; packaged builds stage them alongside.
let geoip_dir = std::env::var("CAPTURE_SIDECAR_GEOIP")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| cfg.repo_root.join("capture-sidecar/geoip"));
let geo = Arc::new(geo::Geo::load(&geoip_dir));

// The live connection monitor — the real, no-root network surface.
netmon::spawn(monitor.clone(), firewall.clone(), geo.clone(), events.clone(), scope.clone());

let state = AppState {
gate: Arc::new(GateConfig::from_repo_root(&cfg.repo_root)),
firewall: Arc::new(Firewall::load(Some(cfg.state_dir.join("firewall.json")))),
monitor: Arc::new(NetworkMonitor::new(2048)),
firewall,
monitor,
events,
session: Arc::new(tokio::sync::Mutex::new(None)),
save_dir: cfg.save_dir,
scope,
geo,
};

let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.port);
Expand Down
42 changes: 38 additions & 4 deletions capture-sidecar/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,50 @@ impl Default for FirewallDecision {
}
}

/// One observed connection, streamed to the cockpit map panel.
/// Which processes the live connection monitor watches.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
/// Only browser processes (bearbrowser/firefox) — "what is my browser talking to".
Browser,
/// Every process's outbound connections — the whole machine.
System,
}

impl Default for Scope {
fn default() -> Self {
Scope::Browser
}
}

/// One observed connection, streamed to the cockpit map/graph panel. Sourced
/// either from the browser's own request hooks (rich: real host + resourceType)
/// or the live OS connection monitor (process + remote addr, no root needed).
#[derive(Clone, Debug, Serialize)]
pub struct ConnectionRecord {
/// eTLD+1 of the endpoint (e.g. "doubleclick.net").
/// Stable identity for the graph: how the node is keyed (process|remote or
/// domain|type). Lets the panel add/update/remove a node in place.
pub key: String,
/// eTLD+1 of the endpoint (e.g. "doubleclick.net"), or reverse-DNS owner /
/// bare IP when that's all the monitor has.
pub domain: String,
#[serde(rename = "pageUrl")]
pub page_url: String,
#[serde(rename = "resourceType")]
pub resource_type: String,
/// Owning local process (live monitor); empty for browser-hook records.
#[serde(default)]
pub process: String,
/// Remote endpoint "ip:port" (live monitor); empty for browser-hook records.
#[serde(default)]
pub remote: String,
pub category: ConnCategory,
pub blocked: bool,
/// Unix seconds.
/// Local IP intelligence (geolocation + ASN/owner), resolved from bundled
/// databases — no network. None for browser-hook records (no remote IP).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geo: Option<crate::geo::GeoInfo>,
/// Unix seconds, last seen.
pub timestamp: u64,
}

Expand All @@ -115,8 +147,10 @@ pub struct ConnectionRecord {
pub enum SidecarEvent {
/// A raw line from the capture engine.
Packet { line: String },
/// A newly observed connection (from the browser's own network telemetry).
/// A new or updated connection (browser telemetry or live OS monitor).
Connection(ConnectionRecord),
/// A connection the live monitor no longer sees — the panel fades its node.
ConnectionClosed { key: String },
/// Capture lifecycle transitions, so the panel can update its controls.
CaptureState {
running: bool,
Expand Down
Loading
Loading