Add OUI vendor lookup for ARP connections (#183)

This commit is contained in:
Marco Cadetg
2026-03-14 13:52:24 +01:00
committed by GitHub
parent d6ba71599f
commit c6323f5e81
13 changed files with 331 additions and 4 deletions
+2
View File
@@ -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:
+50
View File
@@ -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
+15
View File
@@ -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
View File
@@ -2186,6 +2186,7 @@ dependencies = [
"crossterm",
"dashmap",
"dns-lookup",
"flate2",
"http_req",
"landlock",
"libbpf-cargo",
+1
View File
@@ -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]
BIN
View File
Binary file not shown.
+21 -1
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -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;
+168
View File
@@ -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());
}
}
}
+21
View File
@@ -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
View File
@@ -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));
+18
View File
@@ -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,