Skip to main content

zond_engine/core/models/
host.rs

1// Copyright (c) 2026 Erik Lening (hollowpointer) and Contributors
2//
3// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4// If a copy of the MPL was not distributed with this file, You can obtain one at
5// https://mozilla.org/MPL/2.0/.
6
7//! # Host Model
8//!
9//! This module defines the [`Host`] entity, which represents a single network
10//! equipment or device.
11//!
12//! The `Host` model is architected as an **aggregate of findings**. It is
13//! specifically designed to be enriched over multiple asynchronous scanning
14//! stages, leveraging high-performance merging logic to collate reachability,
15//! hardware, OS, and service discovery data into a single forensic record.
16
17use crate::core::models::port::Port;
18use std::{
19    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
20    net::IpAddr,
21    time::SystemTime,
22};
23
24pub mod hardware;
25pub mod os;
26pub mod status;
27pub mod telemetry;
28
29pub use hardware::HardwareInfo;
30pub use os::OsFingerprint;
31pub use status::{HostStatus, StatusProtocol, StatusReason};
32pub use telemetry::HostTelemetry;
33
34/// The absolute maximum number of open ports to record per host.
35///
36/// This serves as a security boundary against network "tarpits" (devices that
37/// report every possible port as open), which could otherwise trigger an
38/// Out-Of-Memory (OOM) crash in the scanner.
39pub const MAX_PORTS_PER_HOST: usize = 1000;
40
41/// Specialized network roles identified during host discovery.
42#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
43pub enum NetworkRole {
44    /// Identifies the default gateway for a local subnet.
45    Gateway,
46    /// Identifies a host providing DHCP services.
47    DHCP,
48    /// Identifies a host providing DNS services.
49    DNS,
50    /// Identifies a host that has triggered defensive limits (e.g., reporting
51    /// an impossible number of open ports).
52    Tarpit,
53}
54
55/// A comprehensive identity and state record for a network-reachable host.
56///
57/// A `Host` is the primary unit of work for the Zond scanner. It holds
58/// multi-homed IP data, forensic hardware IDs, and a history of network
59/// performance metrics.
60///
61/// Large metadata blocks (like [`OsFingerprint`]) are stored in Boxes to
62/// minimize the overall stack size of the `Host` struct.
63#[derive(Debug, Clone)]
64pub struct Host {
65    /// The primary IP address used to target or identify this host.
66    primary_ip: IpAddr,
67
68    /// All known IP addresses for this host (multi-homed support).
69    ips: BTreeSet<IpAddr>,
70
71    /// The resolved hostname (FQDN or local network name).
72    hostname: Option<String>,
73
74    /// The current reachability status.
75    status: HostStatus,
76
77    /// Aggregated evidence explaining the current reachability status.
78    reasons: HashSet<StatusReason>,
79
80    /// Identified operating system metadata.
81    os: Option<Box<OsFingerprint>>,
82
83    /// physical hardware (MAC) and vendor information.
84    hardware: Option<HardwareInfo>,
85
86    /// Network performance and path telemetry.
87    telemetry: HostTelemetry,
88
89    /// Inferred roles based on network location or discovered services.
90    network_roles: HashSet<NetworkRole>,
91
92    /// Extensible map for scan script results (e.g., Nmap NSE output).
93    scripts: Option<HashMap<String, String>>,
94
95    /// The timestamp of the first discovery event for this host.
96    first_seen: SystemTime,
97
98    /// The timestamp of the most recent discovery or update event.
99    last_seen: SystemTime,
100
101    /// Internal map for sorted port discovery. Limited by [`MAX_PORTS_PER_HOST`].
102    ports: BTreeMap<u16, Port>,
103}
104
105impl Host {
106    /// Creates a new `Host` centered around a primary IP address.
107    ///
108    /// The initial status is always [`HostStatus::Unknown`].
109    pub fn new(primary_ip: IpAddr) -> Self {
110        let mut ips = BTreeSet::new();
111        ips.insert(primary_ip);
112        let now = SystemTime::now();
113
114        Self {
115            primary_ip,
116            ips,
117            hostname: None,
118            status: HostStatus::Unknown,
119            reasons: HashSet::new(),
120            os: None,
121            hardware: None,
122            telemetry: HostTelemetry::default(),
123            network_roles: HashSet::new(),
124            scripts: None,
125            first_seen: now,
126            last_seen: now,
127            ports: BTreeMap::new(),
128        }
129    }
130
131    /// Returns the primary IP address for this host.
132    pub fn primary_ip(&self) -> IpAddr {
133        self.primary_ip
134    }
135
136    /// Returns all known IP addresses for this host.
137    pub fn ips(&self) -> &BTreeSet<IpAddr> {
138        &self.ips
139    }
140
141    /// Returns the resolved hostname, if any.
142    pub fn hostname(&self) -> Option<&str> {
143        self.hostname.as_deref()
144    }
145
146    /// Returns the current reachability status.
147    pub fn status(&self) -> HostStatus {
148        self.status
149    }
150
151    /// Returns all aggregated evidence for the current status.
152    pub fn reasons(&self) -> &HashSet<StatusReason> {
153        &self.reasons
154    }
155
156    /// Returns the identified operating system, if any.
157    pub fn os(&self) -> Option<&OsFingerprint> {
158        self.os.as_deref()
159    }
160
161    /// Returns physical hardware information, if any.
162    pub fn hardware(&self) -> Option<&HardwareInfo> {
163        self.hardware.as_ref()
164    }
165
166    /// Returns network performance and path telemetry.
167    pub fn telemetry(&self) -> &HostTelemetry {
168        &self.telemetry
169    }
170
171    /// Returns inferred roles based on network location or discovered services.
172    pub fn network_roles(&self) -> &HashSet<NetworkRole> {
173        &self.network_roles
174    }
175
176    /// Returns the map of scan script results.
177    pub fn scripts(&self) -> Option<&HashMap<String, String>> {
178        self.scripts.as_ref()
179    }
180
181    /// Returns the timestamp of the first discovery event.
182    pub fn first_seen(&self) -> SystemTime {
183        self.first_seen
184    }
185
186    /// Returns the timestamp of the most recent discovery or update event.
187    pub fn last_seen(&self) -> SystemTime {
188        self.last_seen
189    }
190
191    /// Updates the primary IP, ensures it exists in the `ips` set,
192    /// and bumps the `last_seen` timestamp.
193    pub fn set_primary_ip(&mut self, ip: IpAddr) {
194        self.primary_ip = ip;
195        self.ips.insert(ip);
196        self.last_seen = SystemTime::now();
197    }
198
199    /// Adds a new IP address to the host's record and bumps `last_seen`.
200    /// Returns `true` if the IP was newly added.
201    pub fn add_ip(&mut self, ip: IpAddr) -> bool {
202        let is_new = self.ips.insert(ip);
203        self.last_seen = SystemTime::now();
204        is_new
205    }
206
207    /// Adds multiple IP addresses to the host's record and bumps `last_seen`.
208    pub fn extend_ips(&mut self, ips: impl IntoIterator<Item = IpAddr>) {
209        self.ips.extend(ips);
210        self.last_seen = SystemTime::now();
211    }
212
213    /// Sets the host's hostname and bumps `last_seen`.
214    pub fn set_hostname(&mut self, hostname: Option<String>) {
215        self.hostname = hostname;
216        self.last_seen = SystemTime::now();
217    }
218
219    /// Updates the reachability status and bumps `last_seen`.
220    pub fn set_status(&mut self, status: HostStatus) {
221        self.status = status;
222        self.last_seen = SystemTime::now();
223    }
224
225    /// Adds a status reason and bumps `last_seen`.
226    pub fn add_reason(&mut self, reason: StatusReason) {
227        self.reasons.insert(reason);
228        self.last_seen = SystemTime::now();
229    }
230
231    /// Sets the OS fingerprint and bumps `last_seen`.
232    pub fn set_os(&mut self, os: OsFingerprint) {
233        self.os = Some(Box::new(os));
234        self.last_seen = SystemTime::now();
235    }
236
237    /// Sets the hardware info and bumps `last_seen`.
238    pub fn set_hardware(&mut self, hardware: HardwareInfo) {
239        self.hardware = Some(hardware);
240        self.last_seen = SystemTime::now();
241    }
242
243    /// Builder method to set the hardware MAC and return Self.
244    pub fn with_mac(mut self, mac: crate::core::models::mac::MacAddr) -> Self {
245        self.set_hardware(HardwareInfo::new(mac));
246        self
247    }
248
249    /// Adds a single RTT measurement and bumps `last_seen`.
250    pub fn add_rtt(&mut self, rtt: std::time::Duration) {
251        self.telemetry.add_rtt(rtt);
252        self.last_seen = SystemTime::now();
253    }
254
255    /// Builder method to add a single RTT measurement and return Self.
256    pub fn with_rtt(mut self, rtt: std::time::Duration) -> Self {
257        self.add_rtt(rtt);
258        self
259    }
260
261    /// Adds multiple RTT measurements and bumps `last_seen`.
262    pub fn set_rtts(&mut self, rtts: impl IntoIterator<Item = std::time::Duration>) {
263        for rtt in rtts {
264            self.telemetry.add_rtt(rtt);
265        }
266        self.last_seen = SystemTime::now();
267    }
268
269    /// Adds a network role and bumps `last_seen`.
270    pub fn add_network_role(&mut self, role: NetworkRole) {
271        self.network_roles.insert(role);
272        self.last_seen = SystemTime::now();
273    }
274
275    /// Adds or updates a script result and bumps `last_seen`.
276    pub fn add_script_result(&mut self, key: String, value: String) {
277        self.scripts
278            .get_or_insert_with(HashMap::new)
279            .insert(key, value);
280        self.last_seen = SystemTime::now();
281    }
282
283    /// Returns the minimum recorded RTT.
284    pub fn min_rtt(&self) -> Option<std::time::Duration> {
285        self.telemetry.min_rtt()
286    }
287
288    /// Returns the maximum recorded RTT.
289    pub fn max_rtt(&self) -> Option<std::time::Duration> {
290        self.telemetry.max_rtt()
291    }
292
293    /// Returns the average recorded RTT.
294    pub fn average_rtt(&self) -> Option<std::time::Duration> {
295        self.telemetry.average_rtt()
296    }
297
298    /// Returns the most recent MAC address, if hardware info is available.
299    pub fn mac(&self) -> Option<crate::core::models::mac::MacAddr> {
300        self.hardware.as_ref().and_then(|h| h.most_recent_mac())
301    }
302
303    /// Returns the hardware vendor, if hardware info is available.
304    pub fn vendor(&self) -> Option<&str> {
305        self.hardware
306            .as_ref()
307            .and_then(|h| h.vendor.as_ref())
308            .map(|v| &**v)
309    }
310
311    /// Returns `true` if this host is confirmed to be on the network
312    /// (either fully responding or filtered).
313    pub fn is_alive(&self) -> bool {
314        self.status.is_alive()
315    }
316
317    /// Returns an iterator over all discovered ports in sorted order.
318    pub fn ports(&self) -> impl Iterator<Item = &Port> {
319        self.ports.values()
320    }
321
322    /// Returns the total number of recorded ports for this host.
323    pub fn port_count(&self) -> usize {
324        self.ports.len()
325    }
326
327    /// Ingests a new port finding.
328    ///
329    /// If the port is already known, it is merged with the existing record.
330    /// If the total port count exceeds [`MAX_PORTS_PER_HOST`], the port is ignored
331    /// and the host is assigned the [`NetworkRole::Tarpit`] role.
332    pub fn add_port(&mut self, new_port: Port) {
333        if self.ports.len() >= MAX_PORTS_PER_HOST && !self.ports.contains_key(&new_port.number()) {
334            self.network_roles.insert(NetworkRole::Tarpit);
335            return;
336        }
337
338        self.ports
339            .entry(new_port.number())
340            .and_modify(|p| p.merge(new_port.clone()))
341            .or_insert(new_port);
342
343        self.last_seen = SystemTime::now();
344    }
345
346    /// Merges architectural findings from another `Host` record.
347    ///
348    /// This is the core aggregation method of the library, used to combine
349    /// results from multiple asynchronous scan stages into a single truth.
350    ///
351    /// - Status is promoted based on semantic ordering.
352    /// - Telemetry and OS data are merged using their respective logic.
353    /// - Port caps are enforced during aggregation.
354    pub fn merge(&mut self, other: Host) {
355        self.ips.extend(other.ips);
356        if self.hostname.is_none() {
357            self.hostname = other.hostname;
358        }
359
360        if other.status > self.status {
361            self.status = other.status;
362        }
363        self.reasons.extend(other.reasons);
364
365        if let Some(other_os) = other.os {
366            if let Some(ref mut self_os) = self.os {
367                self_os.merge(*other_os);
368            } else {
369                self.os = Some(other_os);
370            }
371        }
372
373        if let Some(other_hw) = other.hardware {
374            if let Some(ref mut self_hw) = self.hardware {
375                self_hw.merge(other_hw);
376            } else {
377                self.hardware = Some(other_hw);
378            }
379        }
380
381        self.telemetry.merge(other.telemetry);
382        self.network_roles.extend(other.network_roles);
383
384        if let Some(other_scripts) = other.scripts {
385            let self_scripts = self.scripts.get_or_insert_with(HashMap::new);
386            self_scripts.extend(other_scripts);
387        }
388
389        for (_, port) in other.ports {
390            self.add_port(port);
391        }
392
393        if other.first_seen < self.first_seen {
394            self.first_seen = other.first_seen;
395        }
396        if other.last_seen > self.last_seen {
397            self.last_seen = other.last_seen;
398        }
399    }
400}
401
402impl std::fmt::Display for Host {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        write!(f, "{} ({})", self.primary_ip, self.status)?;
405        if let Some(ref os) = self.os {
406            write!(f, " - {}", os)?;
407        }
408        if self.network_roles.contains(&NetworkRole::Tarpit) {
409            write!(f, " [TARPIT]")?;
410        } else {
411            write!(f, " [{}]", self.telemetry)?;
412        }
413        Ok(())
414    }
415}
416
417// ╔════════════════════════════════════════════╗
418// ║ ████████╗███████╗███████╗████████╗███████╗ ║
419// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
420// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
421// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
422// ║    ██║   ███████╗███████║   ██║   ███████║ ║
423// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
424// ╚════════════════════════════════════════════╝
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::core::models::port::{Port, PortState, Protocol};
430    use std::net::Ipv4Addr;
431
432    static IP_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 100));
433
434    #[test]
435    fn host_primary_ip_invariant() {
436        let mut host = Host::new(IP_ADDR);
437        let fresh_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
438        host.set_primary_ip(fresh_ip);
439
440        assert_eq!(host.primary_ip(), fresh_ip);
441        assert!(host.ips().contains(&fresh_ip));
442    }
443
444    #[test]
445    fn tarpit_boundary_test() {
446        let mut host = Host::new(IP_ADDR);
447        for i in 0..MAX_PORTS_PER_HOST {
448            host.add_port(Port::new(i as u16, Protocol::Tcp, PortState::Open));
449        }
450        assert!(!host.network_roles.contains(&NetworkRole::Tarpit));
451
452        host.add_port(Port::new(9999, Protocol::Tcp, PortState::Open));
453        assert!(host.network_roles.contains(&NetworkRole::Tarpit));
454        assert_eq!(host.port_count(), MAX_PORTS_PER_HOST);
455    }
456
457    #[test]
458    fn host_merge_promotes_status() {
459        let mut h1 = Host::new(IP_ADDR);
460        h1.set_status(HostStatus::Down);
461
462        let mut h2 = Host::new(IP_ADDR);
463        h2.set_status(HostStatus::Filtered);
464
465        h1.merge(h2);
466        assert_eq!(h1.status(), HostStatus::Filtered);
467    }
468
469    #[test]
470    fn merge_tarpit_collision_test() {
471        let mut h1 = Host::new(IP_ADDR);
472        for i in 0..600 {
473            h1.add_port(Port::new(i, Protocol::Tcp, PortState::Open));
474        }
475
476        let mut h2 = Host::new(IP_ADDR);
477        // Ports 500-1100. 100 overlap (0-indexed 500-599), 500 new ones.
478        // Total should hit cap at 1000.
479        for i in 500..1100 {
480            h2.add_port(Port::new(i, Protocol::Tcp, PortState::Open));
481        }
482
483        h1.merge(h2);
484        assert_eq!(h1.port_count(), MAX_PORTS_PER_HOST);
485        assert!(h1.network_roles.contains(&NetworkRole::Tarpit));
486    }
487}