use std::net::{Ipv4Addr, Ipv6Addr};
use bitflags::bitflags;
use crate::{MacAddr, Timestamp};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Asset {
pub mac: MacAddr,
pub ipv4: Vec<Ipv4Addr>,
pub ipv6: Vec<Ipv6Addr>,
pub hostname: Option<String>,
pub fqdn: Option<String>,
pub vendor_banner: Option<String>,
pub platform: Option<String>,
pub capabilities: AssetCapabilities,
pub fingerprints: AssetFingerprints,
pub seen_via: AssetSourceSet,
pub last_seen: Timestamp,
}
impl Asset {
pub fn new(mac: MacAddr) -> Self {
Self {
mac,
ipv4: Vec::new(),
ipv6: Vec::new(),
hostname: None,
fqdn: None,
vendor_banner: None,
platform: None,
capabilities: AssetCapabilities::empty(),
fingerprints: AssetFingerprints::default(),
seen_via: AssetSourceSet::empty(),
last_seen: Timestamp::default(),
}
}
pub fn merge(&mut self, other: &Asset) {
debug_assert_eq!(self.mac, other.mac, "merge across different MAC keys");
for ip in &other.ipv4 {
if !self.ipv4.contains(ip) {
push_bounded(&mut self.ipv4, *ip, MAX_IPS_PER_ASSET);
}
}
for ip in &other.ipv6 {
if !self.ipv6.contains(ip) {
push_bounded(&mut self.ipv6, *ip, MAX_IPS_PER_ASSET);
}
}
if other.hostname.is_some() {
self.hostname = other.hostname.clone();
}
if other.fqdn.is_some() {
self.fqdn = other.fqdn.clone();
}
if other.vendor_banner.is_some() {
self.vendor_banner = other.vendor_banner.clone();
}
if other.platform.is_some() {
self.platform = other.platform.clone();
}
self.capabilities |= other.capabilities;
self.fingerprints.merge_from(&other.fingerprints);
self.seen_via |= other.seen_via;
if other.last_seen > self.last_seen {
self.last_seen = other.last_seen;
}
}
}
const MAX_IPS_PER_ASSET: usize = 4;
fn push_bounded<T: Clone>(v: &mut Vec<T>, item: T, cap: usize) {
if v.len() >= cap {
v.remove(0); }
v.push(item);
}
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct AssetCapabilities: u32 {
const BRIDGE = 1 << 0;
const ROUTER = 1 << 1;
const SWITCH = 1 << 2;
const WLAN_AP = 1 << 3;
const PHONE = 1 << 4;
const IGMP = 1 << 5;
const REPEATER = 1 << 6;
const DOCSIS_CABLE = 1 << 7;
const SOURCE_BRIDGE = 1 << 8;
const HOST = 1 << 9;
const REMOTELY_MANAGED = 1 << 10;
const UPNP = 1 << 11;
const C_VLAN = 1 << 12;
const S_VLAN = 1 << 13;
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for AssetCapabilities {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_u32(self.bits())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AssetCapabilities {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let bits = <u32 as serde::Deserialize>::deserialize(deserializer)?;
Ok(AssetCapabilities::from_bits_truncate(bits))
}
}
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct AssetSourceSet: u32 {
const ARP = 1 << 0;
const NDP = 1 << 1;
const DHCP = 1 << 2;
const LLDP = 1 << 3;
const CDP = 1 << 4;
const SSDP = 1 << 5;
const MDNS = 1 << 6;
const NBNS = 1 << 7;
const OTHER = 1 << 31;
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for AssetSourceSet {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_u32(self.bits())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AssetSourceSet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let bits = <u32 as serde::Deserialize>::deserialize(deserializer)?;
Ok(AssetSourceSet::from_bits_truncate(bits))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct AssetFingerprints {
pub dhcp: Option<String>,
pub p0f: Option<String>,
pub hassh: Option<String>,
pub ja3: Option<String>,
pub ja4: Option<String>,
}
impl AssetFingerprints {
fn merge_from(&mut self, other: &AssetFingerprints) {
if other.dhcp.is_some() {
self.dhcp = other.dhcp.clone();
}
if other.p0f.is_some() {
self.p0f = other.p0f.clone();
}
if other.hassh.is_some() {
self.hassh = other.hassh.clone();
}
if other.ja3.is_some() {
self.ja3 = other.ja3.clone();
}
if other.ja4.is_some() {
self.ja4 = other.ja4.clone();
}
}
}
#[cfg(feature = "arp")]
impl Asset {
pub fn from_arp(arp: &crate::arp::ArpMessage) -> Self {
let mut a = Self::new(arp.sender);
a.ipv4.push(arp.sender_ip);
a.capabilities |= AssetCapabilities::HOST;
a.seen_via |= AssetSourceSet::ARP;
a
}
}
#[cfg(feature = "ndp")]
impl Asset {
pub fn from_ndp(ndp: &crate::ndp::NdpMessage) -> Option<Self> {
let mac = ndp.lladdr?;
let mut a = Self::new(mac);
a.ipv6.push(ndp.target);
a.capabilities |= AssetCapabilities::HOST;
a.seen_via |= AssetSourceSet::NDP;
Some(a)
}
}
#[cfg(feature = "dhcp")]
impl Asset {
pub fn from_dhcp(dhcp: &crate::dhcp::DhcpMessage) -> Option<Self> {
let mac = dhcp.client_mac?;
let mut a = Self::new(mac);
if !dhcp.ciaddr.is_unspecified() {
a.ipv4.push(dhcp.ciaddr);
}
if !dhcp.yiaddr.is_unspecified() && !a.ipv4.contains(&dhcp.yiaddr) {
a.ipv4.push(dhcp.yiaddr);
}
a.hostname = dhcp.hostname.clone();
a.fqdn = dhcp.client_fqdn.clone();
a.vendor_banner = dhcp.vendor_class.clone();
a.fingerprints.dhcp = dhcp.fingerprint();
a.capabilities |= AssetCapabilities::HOST;
a.seen_via |= AssetSourceSet::DHCP;
Some(a)
}
}
#[cfg(feature = "lldp")]
impl Asset {
pub fn from_lldp(lldp: &crate::lldp::LldpMessage) -> Option<Self> {
let mac = match &lldp.chassis_id {
crate::lldp::ChassisId::MacAddress(m) => *m,
_ => return None,
};
let mut a = Self::new(mac);
if let Some(name) = &lldp.system_name
&& let Ok(s) = std::str::from_utf8(name)
{
a.hostname = Some(s.to_string());
}
if let Some(desc) = &lldp.system_description
&& let Ok(s) = std::str::from_utf8(desc)
{
a.vendor_banner = Some(s.to_string());
}
if let Some(caps) = &lldp.capabilities {
a.capabilities |= lldp_caps_to_asset(caps.system);
}
for mgmt in &lldp.management_addresses {
if let Some(std::net::IpAddr::V4(v4)) = mgmt.ip {
if !a.ipv4.contains(&v4) {
push_bounded(&mut a.ipv4, v4, MAX_IPS_PER_ASSET);
}
} else if let Some(std::net::IpAddr::V6(v6)) = mgmt.ip
&& !a.ipv6.contains(&v6)
{
push_bounded(&mut a.ipv6, v6, MAX_IPS_PER_ASSET);
}
}
a.seen_via |= AssetSourceSet::LLDP;
Some(a)
}
}
#[cfg(feature = "cdp")]
impl Asset {
pub fn from_cdp(cdp: &crate::cdp::CdpMessage, source_mac: MacAddr) -> Self {
let mut a = Self::new(source_mac);
if let Some(devid) = &cdp.device_id
&& let Ok(s) = std::str::from_utf8(devid)
{
a.hostname = Some(s.to_string());
}
if let Some(sw) = &cdp.software_version
&& let Ok(s) = std::str::from_utf8(sw)
{
a.vendor_banner = Some(s.to_string());
}
if let Some(plat) = &cdp.platform
&& let Ok(s) = std::str::from_utf8(plat)
{
a.platform = Some(s.to_string());
}
if let Some(caps) = cdp.capabilities {
a.capabilities |= cdp_caps_to_asset(caps);
}
for addr in cdp.management_addresses.iter().chain(cdp.addresses.iter()) {
match addr.ip {
Some(std::net::IpAddr::V4(v4)) if !a.ipv4.contains(&v4) => {
push_bounded(&mut a.ipv4, v4, MAX_IPS_PER_ASSET);
}
Some(std::net::IpAddr::V6(v6)) if !a.ipv6.contains(&v6) => {
push_bounded(&mut a.ipv6, v6, MAX_IPS_PER_ASSET);
}
_ => {}
}
}
a.seen_via |= AssetSourceSet::CDP;
a
}
}
#[cfg(feature = "ssdp")]
impl Asset {
pub fn from_ssdp(ssdp: &crate::ssdp::SsdpMessage, source_mac: MacAddr) -> Self {
let mut a = Self::new(source_mac);
a.vendor_banner = ssdp.server.clone();
a.capabilities |= AssetCapabilities::UPNP | AssetCapabilities::HOST;
a.seen_via |= AssetSourceSet::SSDP;
a
}
}
#[cfg(feature = "mdns")]
impl Asset {
pub fn from_mdns(resp: &crate::dns::DnsResponse, source_mac: MacAddr) -> Option<Self> {
let mut a = Self::new(source_mac);
let mut populated = false;
let all_records = resp.answers.iter().chain(resp.additionals.iter());
for rr in all_records {
let original = rr.name.strip_suffix('.').unwrap_or(&rr.name);
let lower = original.to_ascii_lowercase();
let Some(_) = lower.strip_suffix(".local") else {
continue;
};
let hostname_orig = &original[..original.len() - ".local".len()];
let leftmost = hostname_orig.split('.').next().unwrap_or(hostname_orig);
match &rr.data {
crate::dns::DnsRdata::A(v4) => {
if !a.ipv4.contains(v4) {
push_bounded(&mut a.ipv4, *v4, MAX_IPS_PER_ASSET);
}
if a.hostname.is_none() && !leftmost.is_empty() {
a.hostname = Some(leftmost.to_string());
}
populated = true;
}
crate::dns::DnsRdata::AAAA(v6) => {
if !a.ipv6.contains(v6) {
push_bounded(&mut a.ipv6, *v6, MAX_IPS_PER_ASSET);
}
if a.hostname.is_none() && !leftmost.is_empty() {
a.hostname = Some(leftmost.to_string());
}
populated = true;
}
_ => {}
}
}
let services = crate::mdns::extract_services(resp);
if !services.is_empty() {
a.capabilities |= AssetCapabilities::UPNP | AssetCapabilities::HOST;
populated = true;
} else if populated {
a.capabilities |= AssetCapabilities::HOST;
}
if populated {
a.seen_via |= AssetSourceSet::MDNS;
Some(a)
} else {
None
}
}
}
#[cfg(feature = "netbios-ns")]
impl Asset {
pub fn from_netbios_ns(
nb: &crate::netbios_ns::NbnsMessage,
source_mac: MacAddr,
) -> Option<Self> {
if nb.queried_name.is_none() && nb.answer_addresses.is_empty() {
return None;
}
let mut a = Self::new(source_mac);
if let Some(name) = nb.queried_name.as_deref() {
a.hostname = Some(name.to_string());
}
for v4 in nb.answer_addresses.iter() {
if !a.ipv4.contains(v4) {
push_bounded(&mut a.ipv4, *v4, MAX_IPS_PER_ASSET);
}
}
a.capabilities |= AssetCapabilities::HOST;
a.seen_via |= AssetSourceSet::NBNS;
Some(a)
}
}
#[cfg(feature = "lldp")]
fn lldp_caps_to_asset(c: crate::lldp::CapabilityBits) -> AssetCapabilities {
use crate::lldp::CapabilityBits as L;
let mut out = AssetCapabilities::empty();
if c.contains(L::BRIDGE) {
out |= AssetCapabilities::BRIDGE;
}
if c.contains(L::ROUTER) {
out |= AssetCapabilities::ROUTER;
}
if c.contains(L::WLAN_AP) {
out |= AssetCapabilities::WLAN_AP;
}
if c.contains(L::TELEPHONE) {
out |= AssetCapabilities::PHONE;
}
if c.contains(L::REPEATER) {
out |= AssetCapabilities::REPEATER;
}
if c.contains(L::DOCSIS_CABLE) {
out |= AssetCapabilities::DOCSIS_CABLE;
}
if c.contains(L::STATION_ONLY) {
out |= AssetCapabilities::HOST;
}
if c.contains(L::C_VLAN) {
out |= AssetCapabilities::C_VLAN;
}
if c.contains(L::S_VLAN) {
out |= AssetCapabilities::S_VLAN;
}
out
}
#[cfg(feature = "cdp")]
fn cdp_caps_to_asset(c: crate::cdp::CdpCapabilities) -> AssetCapabilities {
use crate::cdp::CdpCapabilities as C;
let mut out = AssetCapabilities::empty();
if c.contains(C::ROUTER) {
out |= AssetCapabilities::ROUTER;
}
if c.contains(C::BRIDGE) {
out |= AssetCapabilities::BRIDGE;
}
if c.contains(C::SOURCE_BRIDGE) {
out |= AssetCapabilities::SOURCE_BRIDGE;
}
if c.contains(C::SWITCH) {
out |= AssetCapabilities::SWITCH;
}
if c.contains(C::HOST) {
out |= AssetCapabilities::HOST;
}
if c.contains(C::IGMP) {
out |= AssetCapabilities::IGMP;
}
if c.contains(C::REPEATER) {
out |= AssetCapabilities::REPEATER;
}
if c.contains(C::PHONE) {
out |= AssetCapabilities::PHONE;
}
if c.contains(C::REMOTELY_MANAGED) {
out |= AssetCapabilities::REMOTELY_MANAGED;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_asset_starts_empty() {
let a = Asset::new(MacAddr([1, 2, 3, 4, 5, 6]));
assert_eq!(a.mac, MacAddr([1, 2, 3, 4, 5, 6]));
assert!(a.ipv4.is_empty());
assert!(a.ipv6.is_empty());
assert_eq!(a.seen_via, AssetSourceSet::empty());
}
#[test]
fn merge_unions_capabilities_and_seen_via() {
let mac = MacAddr([0xaa; 6]);
let mut a = Asset::new(mac);
a.capabilities |= AssetCapabilities::HOST;
a.seen_via |= AssetSourceSet::DHCP;
let mut b = Asset::new(mac);
b.capabilities |= AssetCapabilities::ROUTER;
b.seen_via |= AssetSourceSet::LLDP;
a.merge(&b);
assert!(a.capabilities.contains(AssetCapabilities::HOST));
assert!(a.capabilities.contains(AssetCapabilities::ROUTER));
assert!(a.seen_via.contains(AssetSourceSet::DHCP));
assert!(a.seen_via.contains(AssetSourceSet::LLDP));
}
#[test]
fn merge_dedupes_ips_and_caps_at_bound() {
let mac = MacAddr([0xaa; 6]);
let mut a = Asset::new(mac);
a.ipv4 = vec![
Ipv4Addr::new(10, 0, 0, 1),
Ipv4Addr::new(10, 0, 0, 2),
Ipv4Addr::new(10, 0, 0, 3),
];
let mut b = Asset::new(mac);
b.ipv4 = vec![
Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(10, 0, 0, 4),
Ipv4Addr::new(10, 0, 0, 5), ];
a.merge(&b);
assert_eq!(a.ipv4.len(), MAX_IPS_PER_ASSET);
assert!(
!a.ipv4.contains(&Ipv4Addr::new(10, 0, 0, 1)),
"oldest should have been evicted to make room"
);
assert!(a.ipv4.contains(&Ipv4Addr::new(10, 0, 0, 5)));
}
#[test]
fn fingerprints_overwrite_on_merge() {
let mac = MacAddr([0xaa; 6]);
let mut a = Asset::new(mac);
a.fingerprints.dhcp = Some("OLD".into());
let mut b = Asset::new(mac);
b.fingerprints.dhcp = Some("NEW".into());
a.merge(&b);
assert_eq!(a.fingerprints.dhcp.as_deref(), Some("NEW"));
}
#[test]
fn fingerprints_keep_when_other_is_none() {
let mac = MacAddr([0xaa; 6]);
let mut a = Asset::new(mac);
a.fingerprints.dhcp = Some("ORIGINAL".into());
let b = Asset::new(mac);
a.merge(&b);
assert_eq!(a.fingerprints.dhcp.as_deref(), Some("ORIGINAL"));
}
}