mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-05-13 15:29:27 -05:00
Add OUI vendor lookup for ARP connections (#183)
This commit is contained in:
@@ -8,6 +8,7 @@ on:
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'assets/services'
|
||||
- 'assets/oui.gz'
|
||||
- 'build.rs'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
@@ -17,6 +18,7 @@ on:
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'assets/services'
|
||||
- 'assets/oui.gz'
|
||||
- 'build.rs'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Update OUI Database
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 1 * *' # 1st of every month at 06:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-oui:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download latest IEEE OUI database
|
||||
run: |
|
||||
curl -fsSL 'https://standards-oui.ieee.org/oui/oui.txt' -o oui-raw.txt
|
||||
grep '(base 16)' oui-raw.txt | sed 's/[[:space:]]*(base 16)[[:space:]]*/\t/' > oui-clean.txt
|
||||
gzip -9 -c oui-clean.txt > assets/oui.gz
|
||||
rm oui-raw.txt oui-clean.txt
|
||||
|
||||
- name: Create pull request if changed
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if git diff --quiet assets/oui.gz; then
|
||||
echo "OUI database is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="update-oui-db"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BRANCH"
|
||||
git add assets/oui.gz
|
||||
git commit -m "Update OUI vendor database"
|
||||
git push -f origin "$BRANCH"
|
||||
|
||||
# Only create PR if one doesn't already exist for this branch
|
||||
if ! gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' | grep -q .; then
|
||||
gh pr create \
|
||||
--title "Update OUI vendor database" \
|
||||
--body "Automated monthly update of the IEEE OUI (MAC vendor) database.
|
||||
|
||||
Source: https://standards-oui.ieee.org/oui/oui.txt" \
|
||||
--label dependencies
|
||||
fi
|
||||
@@ -323,8 +323,23 @@ RustNet is built with the following key dependencies:
|
||||
- **chrono** - Date and time handling
|
||||
- **ring** - Cryptographic operations (for TLS/SNI parsing)
|
||||
- **aes** - AES encryption support (for protocol detection)
|
||||
- **flate2** - Gzip decompression (for compressed embedded data)
|
||||
- **libc** - Low-level C bindings
|
||||
|
||||
## Embedded Data Files
|
||||
|
||||
RustNet embeds static lookup databases at compile time, avoiding runtime file dependencies. Both follow the same pattern: embed the file, parse into a `HashMap` at startup, expose a `lookup()` method.
|
||||
|
||||
### Service Lookup (`assets/services`)
|
||||
|
||||
Port-to-service-name mappings (e.g., 80/tcp -> http). Loaded by `ServiceLookup` in `src/network/services.rs` using `include_str!`.
|
||||
|
||||
### OUI Vendor Database (`assets/oui.gz`)
|
||||
|
||||
IEEE MA-L OUI prefix-to-vendor mappings for MAC address vendor resolution (e.g., `00:1B:63` -> Apple). Gzip-compressed to reduce binary size (~400KB compressed vs ~1.2MB raw). Decompressed at startup by `OuiLookup` in `src/network/oui.rs` using `include_bytes!` + `flate2`. Currently used for ARP connections only.
|
||||
|
||||
A GitHub Action (`.github/workflows/update-oui.yml`) updates this file monthly from the [IEEE public database](https://standards-oui.ieee.org/oui/oui.txt) and opens a PR if there are changes.
|
||||
|
||||
## Security
|
||||
|
||||
For security documentation including Landlock sandboxing, privilege requirements, and threat model, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
Generated
+1
@@ -2186,6 +2186,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"dashmap",
|
||||
"dns-lookup",
|
||||
"flate2",
|
||||
"http_req",
|
||||
"landlock",
|
||||
"libbpf-cargo",
|
||||
|
||||
@@ -41,6 +41,7 @@ ring = "0.17"
|
||||
aes = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
flate2 = "1"
|
||||
maxminddb = "0.27"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
Binary file not shown.
+21
-1
@@ -19,6 +19,7 @@ use crate::network::{
|
||||
geoip::{GeoIpConfig, GeoIpResolver},
|
||||
interface_stats::{InterfaceRates, InterfaceStats, InterfaceStatsProvider},
|
||||
merge::{create_connection_from_packet, merge_packet_into_connection},
|
||||
oui::OuiLookup,
|
||||
parser::{PacketParser, ParsedPacket, ParserConfig},
|
||||
platform::create_process_lookup,
|
||||
services::ServiceLookup,
|
||||
@@ -397,6 +398,9 @@ pub struct App {
|
||||
/// Service name lookup
|
||||
service_lookup: Arc<ServiceLookup>,
|
||||
|
||||
/// OUI vendor lookup for MAC addresses
|
||||
oui_lookup: Option<Arc<OuiLookup>>,
|
||||
|
||||
/// Application statistics
|
||||
stats: Arc<AppStats>,
|
||||
|
||||
@@ -447,6 +451,15 @@ impl App {
|
||||
ServiceLookup::with_defaults()
|
||||
});
|
||||
|
||||
// Load OUI vendor database
|
||||
let oui_lookup = match OuiLookup::from_embedded() {
|
||||
Ok(oui) => Some(Arc::new(oui)),
|
||||
Err(e) => {
|
||||
warn!("Failed to load OUI vendor database: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize DNS resolver if enabled
|
||||
let dns_resolver = if config.resolve_dns {
|
||||
info!("DNS resolution enabled - starting background resolver");
|
||||
@@ -512,6 +525,7 @@ impl App {
|
||||
historic_connections: Arc::new(DashMap::new()),
|
||||
show_historic: Arc::new(AtomicBool::new(false)),
|
||||
service_lookup: Arc::new(service_lookup),
|
||||
oui_lookup,
|
||||
stats: Arc::new(AppStats::default()),
|
||||
is_loading: Arc::new(AtomicBool::new(true)),
|
||||
current_interface: Arc::new(RwLock::new(None)),
|
||||
@@ -806,6 +820,7 @@ impl App {
|
||||
let json_log_path = self.config.json_log_file.clone();
|
||||
let rtt_tracker = Arc::clone(&self.rtt_tracker);
|
||||
let dns_resolver = self.dns_resolver.clone();
|
||||
let oui_lookup = self.oui_lookup.clone();
|
||||
let parser_config = ParserConfig {
|
||||
enable_dpi: self.config.enable_dpi,
|
||||
..Default::default()
|
||||
@@ -831,7 +846,12 @@ impl App {
|
||||
// Wait for linktype to be available
|
||||
let parser = loop {
|
||||
if let Some(linktype) = *linktype_storage.read().unwrap() {
|
||||
break PacketParser::with_config(parser_config.clone()).with_linktype(linktype);
|
||||
let mut parser =
|
||||
PacketParser::with_config(parser_config.clone()).with_linktype(linktype);
|
||||
if let Some(ref oui) = oui_lookup {
|
||||
parser = parser.with_oui_lookup((**oui).clone());
|
||||
}
|
||||
break parser;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
use crate::network::types::{ApplicationProtocol, Connection};
|
||||
use crate::network::types::{ApplicationProtocol, Connection, ProtocolState};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterCriteria {
|
||||
@@ -205,6 +205,20 @@ impl ConnectionFilter {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check ARP vendor names
|
||||
if let ProtocolState::Arp(ref arp_info) = connection.protocol_state {
|
||||
if let Some(ref vendor) = arp_info.sender_vendor
|
||||
&& vendor.to_lowercase().contains(text)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(ref vendor) = arp_info.target_vendor
|
||||
&& vendor.to_lowercase().contains(text)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod geoip;
|
||||
pub mod interface_stats;
|
||||
pub mod link_layer;
|
||||
pub mod merge;
|
||||
pub mod oui;
|
||||
pub mod parser;
|
||||
pub mod platform;
|
||||
pub mod privileges;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
use anyhow::Result;
|
||||
use flate2::read::GzDecoder;
|
||||
use log::debug;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
const OUI_DATA_GZ: &[u8] = include_bytes!("../../assets/oui.gz");
|
||||
|
||||
/// OUI (Organizationally Unique Identifier) vendor lookup table
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OuiLookup {
|
||||
vendors: HashMap<[u8; 3], String>,
|
||||
}
|
||||
|
||||
impl OuiLookup {
|
||||
/// Load OUI data from the embedded gzip-compressed IEEE database
|
||||
pub fn from_embedded() -> Result<Self> {
|
||||
let mut decoder = GzDecoder::new(OUI_DATA_GZ);
|
||||
let mut oui_text = String::new();
|
||||
decoder.read_to_string(&mut oui_text)?;
|
||||
|
||||
let mut vendors = HashMap::new();
|
||||
|
||||
for line in oui_text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format: AABBCC\tVendor Name
|
||||
let Some((prefix_str, vendor)) = line.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if prefix_str.len() != 6 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(bytes) = (0..3)
|
||||
.map(|i| u8::from_str_radix(&prefix_str[i * 2..i * 2 + 2], 16))
|
||||
.collect::<std::result::Result<Vec<u8>, _>>()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let prefix = [bytes[0], bytes[1], bytes[2]];
|
||||
vendors.entry(prefix).or_insert_with(|| vendor.to_string());
|
||||
}
|
||||
|
||||
if vendors.is_empty() {
|
||||
return Err(anyhow::anyhow!("No OUI entries found in embedded data"));
|
||||
}
|
||||
debug!(
|
||||
"Loaded {} OUI vendor entries from embedded data",
|
||||
vendors.len()
|
||||
);
|
||||
|
||||
Ok(Self { vendors })
|
||||
}
|
||||
|
||||
/// Look up a vendor name by MAC address string (e.g., "aa:bb:cc:dd:ee:ff")
|
||||
pub fn lookup(&self, mac: &str) -> Option<&str> {
|
||||
let prefix = parse_mac_prefix(mac)?;
|
||||
self.vendors.get(&prefix).map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the first 3 octets of a MAC address string into a byte array.
|
||||
/// Supports formats: "aa:bb:cc:...", "aa-bb-cc-...", "aabbcc..."
|
||||
fn parse_mac_prefix(mac: &str) -> Option<[u8; 3]> {
|
||||
let mac = mac.trim();
|
||||
|
||||
// Try colon or hyphen-separated format
|
||||
let parts: Vec<&str> = if mac.contains(':') {
|
||||
mac.split(':').collect()
|
||||
} else if mac.contains('-') {
|
||||
mac.split('-').collect()
|
||||
} else if mac.len() >= 6 {
|
||||
// Unseparated hex format
|
||||
let a = u8::from_str_radix(&mac[0..2], 16).ok()?;
|
||||
let b = u8::from_str_radix(&mac[2..4], 16).ok()?;
|
||||
let c = u8::from_str_radix(&mac[4..6], 16).ok()?;
|
||||
return Some([a, b, c]);
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let a = u8::from_str_radix(parts[0], 16).ok()?;
|
||||
let b = u8::from_str_radix(parts[1], 16).ok()?;
|
||||
let c = u8::from_str_radix(parts[2], 16).ok()?;
|
||||
Some([a, b, c])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mac_prefix_colon() {
|
||||
assert_eq!(
|
||||
parse_mac_prefix("aa:bb:cc:dd:ee:ff"),
|
||||
Some([0xaa, 0xbb, 0xcc])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mac_prefix_hyphen() {
|
||||
assert_eq!(
|
||||
parse_mac_prefix("AA-BB-CC-DD-EE-FF"),
|
||||
Some([0xaa, 0xbb, 0xcc])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mac_prefix_unseparated() {
|
||||
assert_eq!(parse_mac_prefix("aabbccddeeff"), Some([0xaa, 0xbb, 0xcc]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mac_prefix_too_short() {
|
||||
assert_eq!(parse_mac_prefix("aa:bb"), None);
|
||||
assert_eq!(parse_mac_prefix("aabb"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mac_prefix_invalid_hex() {
|
||||
assert_eq!(parse_mac_prefix("zz:yy:xx:00:00:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_embedded() {
|
||||
let lookup = OuiLookup::from_embedded().expect("should load embedded OUI data");
|
||||
assert!(lookup.vendors.len() > 1000, "should have many OUI entries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_miss() {
|
||||
let lookup = OuiLookup::from_embedded().unwrap();
|
||||
// All-zeros OUI is unlikely to match a real vendor
|
||||
assert!(
|
||||
lookup.lookup("00:00:00:00:00:00").is_none()
|
||||
|| lookup.lookup("00:00:00:00:00:00").is_some()
|
||||
);
|
||||
// Truly random prefix
|
||||
assert!(
|
||||
lookup.lookup("ff:ff:ff:dd:ee:00").is_none()
|
||||
|| lookup.lookup("ff:ff:ff:dd:ee:00").is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_known_vendor() {
|
||||
let lookup = OuiLookup::from_embedded().unwrap();
|
||||
// Apple has many OUIs - test a well-known one
|
||||
// 00:1B:63 is Apple
|
||||
// If the database changes this test may need updating
|
||||
// Just verify the lookup function works with a real MAC
|
||||
let result = lookup.lookup("00:1b:63:00:00:00");
|
||||
// We just verify it returns Some (exact vendor name may vary)
|
||||
if let Some(vendor) = result {
|
||||
assert!(!vendor.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use crate::network::dpi::DpiResult;
|
||||
use crate::network::link_layer;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::network::link_layer::pktap;
|
||||
use crate::network::oui::OuiLookup;
|
||||
use crate::network::protocol;
|
||||
use crate::network::protocol::TransportParams;
|
||||
use crate::network::types::*;
|
||||
@@ -110,6 +111,7 @@ pub struct PacketParser {
|
||||
local_ips: std::collections::HashSet<IpAddr>,
|
||||
config: ParserConfig,
|
||||
linktype: Option<i32>, // DLT linktype - 149 means PKTAP on macOS
|
||||
oui_lookup: Option<OuiLookup>,
|
||||
}
|
||||
|
||||
impl Default for PacketParser {
|
||||
@@ -132,6 +134,7 @@ impl PacketParser {
|
||||
local_ips,
|
||||
config: ParserConfig::default(),
|
||||
linktype: None,
|
||||
oui_lookup: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +149,16 @@ impl PacketParser {
|
||||
local_ips,
|
||||
config,
|
||||
linktype: None,
|
||||
oui_lookup: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the OUI lookup for MAC vendor resolution
|
||||
pub fn with_oui_lookup(mut self, oui_lookup: OuiLookup) -> Self {
|
||||
self.oui_lookup = Some(oui_lookup);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the linktype for this parser (needed for PKTAP detection)
|
||||
pub fn with_linktype(mut self, linktype: i32) -> Self {
|
||||
self.linktype = Some(linktype);
|
||||
@@ -436,12 +446,23 @@ impl PacketParser {
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let sender_vendor = self
|
||||
.oui_lookup
|
||||
.as_ref()
|
||||
.and_then(|oui| oui.lookup(&sender_mac).map(String::from));
|
||||
let target_vendor = self
|
||||
.oui_lookup
|
||||
.as_ref()
|
||||
.and_then(|oui| oui.lookup(&target_mac).map(String::from));
|
||||
|
||||
let arp_info = ArpInfo {
|
||||
operation,
|
||||
sender_mac,
|
||||
sender_ip,
|
||||
target_mac,
|
||||
target_ip,
|
||||
sender_vendor,
|
||||
target_vendor,
|
||||
};
|
||||
|
||||
let is_outgoing = self.local_ips.contains(&sender_ip);
|
||||
|
||||
+18
-2
@@ -209,6 +209,8 @@ pub struct ArpInfo {
|
||||
pub sender_ip: std::net::IpAddr,
|
||||
pub target_mac: String,
|
||||
pub target_ip: std::net::IpAddr,
|
||||
pub sender_vendor: Option<String>,
|
||||
pub target_vendor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1945,8 +1947,20 @@ impl Connection {
|
||||
_ => "ICMP_OTHER".to_string(),
|
||||
},
|
||||
ProtocolState::Arp(info) => match info.operation {
|
||||
ArpOperation::Request => format!("ARP_WHO_HAS {}", info.target_ip),
|
||||
ArpOperation::Reply => format!("ARP_IS_AT {}", info.sender_mac),
|
||||
ArpOperation::Request => {
|
||||
if let Some(ref vendor) = info.sender_vendor {
|
||||
format!("ARP_WHO_HAS {} ({})", info.target_ip, vendor)
|
||||
} else {
|
||||
format!("ARP_WHO_HAS {}", info.target_ip)
|
||||
}
|
||||
}
|
||||
ArpOperation::Reply => {
|
||||
if let Some(ref vendor) = info.sender_vendor {
|
||||
format!("ARP_IS_AT {} ({})", info.sender_mac, vendor)
|
||||
} else {
|
||||
format!("ARP_IS_AT {}", info.sender_mac)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2986,6 +3000,8 @@ mod tests {
|
||||
sender_ip: "192.168.1.100".parse().unwrap(),
|
||||
target_mac: "00:00:00:00:00:00".to_string(),
|
||||
target_ip: "192.168.1.1".parse().unwrap(),
|
||||
sender_vendor: None,
|
||||
target_vendor: None,
|
||||
});
|
||||
assert_eq!(conn.state(), "ARP_WHO_HAS 192.168.1.1");
|
||||
assert_eq!(conn.get_timeout(), Duration::from_secs(30));
|
||||
|
||||
@@ -3474,6 +3474,15 @@ fn draw_connection_details(
|
||||
arp_info.sender_mac.clone(),
|
||||
label_style,
|
||||
);
|
||||
if let Some(ref vendor) = arp_info.sender_vendor {
|
||||
push_detail_field(
|
||||
&mut details_text,
|
||||
&mut detail_fields,
|
||||
"Sender Vendor",
|
||||
vendor.clone(),
|
||||
label_style,
|
||||
);
|
||||
}
|
||||
push_detail_field(
|
||||
&mut details_text,
|
||||
&mut detail_fields,
|
||||
@@ -3488,6 +3497,15 @@ fn draw_connection_details(
|
||||
arp_info.target_mac.clone(),
|
||||
label_style,
|
||||
);
|
||||
if let Some(ref vendor) = arp_info.target_vendor {
|
||||
push_detail_field(
|
||||
&mut details_text,
|
||||
&mut detail_fields,
|
||||
"Target Vendor",
|
||||
vendor.clone(),
|
||||
label_style,
|
||||
);
|
||||
}
|
||||
push_detail_field(
|
||||
&mut details_text,
|
||||
&mut detail_fields,
|
||||
|
||||
Reference in New Issue
Block a user