Skip to main content

huginn_net/analyzer/
mod.rs

1mod matchers;
2use matchers::cache_sizes;
3#[cfg(feature = "http-p0f-request")]
4use matchers::HttpRequestMatchResult;
5
6use crate::error::HuginnNetError;
7use crate::output::FingerprintResult;
8use crate::process::ObservablePackage;
9#[cfg(feature = "http-p0f-response")]
10use huginn_net_http::http::HttpDiagnosis;
11use huginn_net_http::http_process::{FlowKey, HttpProcessors, TcpFlow};
12#[cfg(feature = "http-p0f-request")]
13use huginn_net_http::output::HttpRequestOutput;
14#[cfg(feature = "http-p0f-response")]
15use huginn_net_http::output::HttpResponseOutput;
16#[cfg(feature = "tcp-mtu")]
17use huginn_net_tcp::output::MTUOutput;
18#[cfg(feature = "tcp-syn-ack")]
19use huginn_net_tcp::output::SynAckTCPOutput;
20#[cfg(feature = "tcp-syn")]
21use huginn_net_tcp::output::SynTCPOutput;
22#[cfg(feature = "tcp-uptime")]
23use huginn_net_tcp::output::{UptimeOutput, UptimeRole};
24use huginn_net_tcp::ConnectionTracker;
25use huginn_net_tls::output::TlsClientOutput;
26use pcap_file::pcap::PcapReader;
27use pnet::datalink::{self, Channel, Config};
28use std::fs::File;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::mpsc::Sender;
31use std::sync::Arc;
32use tracing::{debug, error};
33use ttl_cache::TtlCache;
34
35#[cfg(feature = "db")]
36use huginn_net_db::Database;
37
38#[cfg(feature = "http-p0f-request")]
39pub use huginn_net_http::observable::ObservableHttpRequest;
40#[cfg(feature = "http-p0f-response")]
41pub use huginn_net_http::observable::ObservableHttpResponse;
42pub use huginn_net_tcp::observable::ObservableTcp;
43use huginn_net_tcp::FilterConfig;
44pub use huginn_net_tls::ObservableTlsClient;
45
46/// Configuration for protocol analysis
47#[derive(Debug, Clone)]
48pub struct AnalysisConfig {
49    /// Enable HTTP protocol analysis
50    pub http_enabled: bool,
51    /// Enable TCP protocol analysis
52    pub tcp_enabled: bool,
53    /// Enable TLS protocol analysis
54    pub tls_enabled: bool,
55    /// Enable fingerprint matching against the database. When false, all quality matched results will be Disabled.
56    pub matcher_enabled: bool,
57}
58
59impl Default for AnalysisConfig {
60    fn default() -> Self {
61        Self { http_enabled: true, tcp_enabled: true, tls_enabled: true, matcher_enabled: true }
62    }
63}
64
65/// A multi-protocol passive fingerprinting analyzer: TCP (p0f-style), HTTP, and TLS (JA4).
66///
67/// Combines all three protocols in a single sequential pass over each packet,
68/// producing a unified [`FingerprintResult`] per packet. For single-protocol
69/// high-throughput use cases, prefer the individual crates
70/// (`huginn-net-tcp`, `huginn-net-http`, `huginn-net-tls`) which support
71/// parallel worker pools.
72///
73/// # Examples
74///
75/// **With p0f database, TCP + HTTP matching enabled (requires `db` feature):**
76///
77/// ```no_run
78/// # #[cfg(all(feature = "db", feature = "tcp-syn", feature = "http-p0f-request"))] {
79/// use huginn_net::{Database, HuginnNet};
80/// use std::sync::mpsc;
81///
82/// let db = Database::load_default().unwrap();
83/// let (tx, rx) = mpsc::channel();
84/// HuginnNet::new(Some(&db), 1000, None)
85///     .unwrap()
86///     .analyze_pcap("capture.pcap", tx, None)
87///     .unwrap();
88/// for result in rx {
89///     if let Some(syn) = result.tcp_syn   { println!("{syn}"); }
90///     if let Some(req) = result.http_request { println!("{req}"); }
91///     if let Some(tls) = result.tls_client  { println!("{tls}"); }
92/// }
93/// # }
94/// ```
95///
96/// **Observation-only, no database, all protocols, matching disabled:**
97///
98/// ```no_run
99/// # #[cfg(feature = "db")] {
100/// use huginn_net::{AnalysisConfig, HuginnNet};
101/// use std::sync::mpsc;
102///
103/// let config = AnalysisConfig { matcher_enabled: false, ..Default::default() };
104/// let (tx, rx) = mpsc::channel();
105/// HuginnNet::new(None, 1000, Some(config))
106///     .unwrap()
107///     .analyze_pcap("capture.pcap", tx, None)
108///     .unwrap();
109/// # }
110/// ```
111pub struct HuginnNet<'a> {
112    #[cfg(feature = "db")]
113    pub tcp_matcher: Option<huginn_net_db::TcpSignatureMatcher<'a>>,
114    #[cfg(feature = "db")]
115    pub http_matcher: Option<huginn_net_db::HttpSignatureMatcher<'a>>,
116    connection_tracker: ConnectionTracker,
117    http_flows: TtlCache<FlowKey, TcpFlow>,
118    http_processors: HttpProcessors,
119    pub(crate) config: AnalysisConfig,
120    filter_config: Option<FilterConfig>,
121    #[cfg(not(feature = "db"))]
122    _lifetime: std::marker::PhantomData<&'a ()>,
123}
124
125impl<'a> HuginnNet<'a> {
126    /// Creates a new instance of `HuginnNet` with database-backed matchers.
127    ///
128    /// # Parameters
129    /// - `database`: Optional reference to the database containing known TCP/Http signatures from p0f.
130    ///   Only loaded if `matcher_enabled` is true and HTTP or TCP analysis is enabled.
131    ///   Not needed for TLS-only analysis or when fingerprint matching is disabled.
132    /// - `max_connections`: The maximum number of connections to maintain in the connection tracker and HTTP flows.
133    /// - `config`: Optional configuration specifying which protocols to analyze. If None, uses default (all enabled).
134    ///   When `matcher_enabled` is false, the database won't be loaded and no signature matching will be performed.
135    ///
136    /// # Returns
137    /// A new `HuginnNet` instance initialized with the given database, max connections, and configuration.
138    ///
139    /// # Errors
140    /// Returns `HuginnNetError::MissConfiguration` if `matcher_enabled` is true but no database is provided.
141    ///
142    /// Available only with the `db` feature (enabled by default). Without
143    /// `db`, use `HuginnNet::new_observable` for an observation-only
144    /// analyzer (no p0f matching).
145    #[cfg(feature = "db")]
146    pub fn new(
147        database: Option<&'a Database>,
148        max_connections: usize,
149        config: Option<AnalysisConfig>,
150    ) -> Result<Self, HuginnNetError> {
151        let config = config.unwrap_or_default();
152
153        if config.matcher_enabled
154            && (config.tcp_enabled || config.http_enabled)
155            && database.is_none()
156        {
157            return Err(HuginnNetError::MissConfiguration(
158                "Database is required when matcher is enabled".to_string(),
159            ));
160        }
161
162        let tcp_matcher = if config.matcher_enabled && config.tcp_enabled {
163            database.map(|db| huginn_net_db::TcpSignatureMatcher::new(&db.tcp))
164        } else {
165            None
166        };
167
168        let http_matcher = if config.matcher_enabled && config.http_enabled {
169            database.map(|db| huginn_net_db::HttpSignatureMatcher::new(&db.http))
170        } else {
171            None
172        };
173
174        let (connection_tracker_size, http_flows_size) = cache_sizes(&config, max_connections);
175
176        Ok(Self {
177            tcp_matcher,
178            http_matcher,
179            connection_tracker: ConnectionTracker::new(connection_tracker_size),
180            http_flows: TtlCache::new(http_flows_size),
181            http_processors: HttpProcessors::new(),
182            config,
183            filter_config: None,
184        })
185    }
186
187    /// Creates a new instance of `HuginnNet` without any p0f signature matcher.
188    ///
189    /// All TCP/HTTP outputs will report `MatchQuality::Disabled` for fingerprint
190    /// matching, but raw observations (TCP/HTTP signatures, TLS JA4 fingerprints,
191    /// uptime, MTU values, etc.) are still produced.
192    ///
193    /// Use this when you want to bring your own matcher implementation, or when
194    /// only TLS / observation-level data is needed.
195    ///
196    /// Available only without the `db` feature. With `db`, use
197    /// [`HuginnNet::new`] and pass `None` as the database to disable matching.
198    #[cfg(not(feature = "db"))]
199    pub fn new_observable(
200        max_connections: usize,
201        config: Option<AnalysisConfig>,
202    ) -> Result<Self, HuginnNetError> {
203        let config = config.unwrap_or_default();
204        let (connection_tracker_size, http_flows_size) = cache_sizes(&config, max_connections);
205
206        Ok(Self {
207            connection_tracker: ConnectionTracker::new(connection_tracker_size),
208            http_flows: TtlCache::new(http_flows_size),
209            http_processors: HttpProcessors::new(),
210            config,
211            filter_config: None,
212            _lifetime: std::marker::PhantomData,
213        })
214    }
215
216    /// Configure packet filtering for this analyzer.
217    ///
218    /// Filters packets by IP address and/or port before processing.
219    /// This is more efficient than processing all packets and filtering later.
220    ///
221    /// # Parameters
222    /// - `filter`: The `FilterConfig` to apply to incoming packets.
223    ///
224    /// # Returns
225    /// A new `HuginnNet` instance with the filter configured.
226    pub fn with_filter(mut self, filter: FilterConfig) -> Self {
227        self.filter_config = Some(filter);
228        self
229    }
230
231    fn process_with<F>(
232        &mut self,
233        mut packet_fn: F,
234        sender: Sender<FingerprintResult>,
235        cancel_signal: Option<Arc<AtomicBool>>,
236    ) -> Result<(), HuginnNetError>
237    where
238        F: FnMut() -> Option<Result<Vec<u8>, HuginnNetError>>,
239    {
240        while let Some(packet_result) = packet_fn() {
241            if let Some(ref cancel) = cancel_signal {
242                if cancel.load(Ordering::Relaxed) {
243                    debug!("Cancellation signal received, stopping packet processing");
244                    break;
245                }
246            }
247
248            match packet_result {
249                Ok(packet) => {
250                    if let Some(ref filter) = self.filter_config {
251                        if !huginn_net_tcp::raw_filter::apply(&packet, filter) {
252                            debug!("Filtered out packet before parsing");
253                            continue;
254                        }
255                    }
256
257                    let output = self.analyze_tcp(&packet);
258                    if sender.send(output).is_err() {
259                        error!("Receiver dropped, stopping packet processing");
260                        break;
261                    }
262                }
263                Err(e) => {
264                    error!("Failed to read packet: {}", e);
265                }
266            }
267        }
268        Ok(())
269    }
270
271    /// Captures and analyzes packets on the specified network interface.
272    ///
273    /// Sends `FingerprintResult` through the provided channel.
274    ///
275    /// # Parameters
276    /// - `interface_name`: The name of the network interface to analyze.
277    /// - `sender`: A `Sender` to send `FingerprintResult` objects back to the caller.
278    /// - `cancel_signal`: Optional `Arc<AtomicBool>` to signal graceful shutdown.
279    ///
280    /// # Errors
281    /// - If the network interface cannot be found or a channel cannot be created.
282    pub fn analyze_network(
283        &mut self,
284        interface_name: &str,
285        sender: Sender<FingerprintResult>,
286        cancel_signal: Option<Arc<AtomicBool>>,
287    ) -> Result<(), HuginnNetError> {
288        let interfaces = datalink::interfaces();
289        let interface = interfaces
290            .into_iter()
291            .find(|iface| iface.name == interface_name)
292            .ok_or_else(|| {
293                HuginnNetError::MissConfiguration(format!(
294                    "Could not find network interface: {interface_name}"
295                ))
296            })?;
297
298        debug!("Using network interface: {}", interface.name);
299
300        let config = Config { promiscuous: true, ..Config::default() };
301
302        let (_tx, mut rx) = match datalink::channel(&interface, config) {
303            Ok(Channel::Ethernet(tx, rx)) => (tx, rx),
304            Ok(_) => {
305                return Err(HuginnNetError::MissConfiguration("Unhandled channel type".to_string()))
306            }
307            Err(e) => {
308                return Err(HuginnNetError::MissConfiguration(format!(
309                    "Unable to create channel: {e}"
310                )))
311            }
312        };
313
314        self.process_with(
315            move || match rx.next() {
316                Ok(packet) => Some(Ok(packet.to_vec())),
317                Err(e) => Some(Err(HuginnNetError::MissConfiguration(format!(
318                    "Error receiving packet: {e}"
319                )))),
320            },
321            sender,
322            cancel_signal,
323        )
324    }
325
326    /// Analyzes packets from a PCAP file.
327    ///
328    /// # Parameters
329    /// - `pcap_path`: The path to the PCAP file to analyze.
330    /// - `sender`: A `Sender` to send `FingerprintResult` objects back to the caller.
331    /// - `cancel_signal`: Optional `Arc<AtomicBool>` to signal graceful shutdown.
332    ///
333    /// # Errors
334    /// - If the PCAP file cannot be opened or read.
335    pub fn analyze_pcap(
336        &mut self,
337        pcap_path: &str,
338        sender: Sender<FingerprintResult>,
339        cancel_signal: Option<Arc<AtomicBool>>,
340    ) -> Result<(), HuginnNetError> {
341        let file = File::open(pcap_path).map_err(|e| {
342            HuginnNetError::MissConfiguration(format!("Failed to open PCAP file: {e}"))
343        })?;
344        let mut pcap_reader = PcapReader::new(file).map_err(|e| {
345            HuginnNetError::MissConfiguration(format!("Failed to create PCAP reader: {e}"))
346        })?;
347
348        self.process_with(
349            move || match pcap_reader.next_packet() {
350                Some(Ok(packet)) => Some(Ok(packet.data.to_vec())),
351                Some(Err(e)) => Some(Err(HuginnNetError::MissConfiguration(format!(
352                    "Error reading PCAP packet: {e}"
353                )))),
354                None => None,
355            },
356            sender,
357            cancel_signal,
358        )
359    }
360
361    /// Analyzes a TCP packet and returns a `FingerprintResult` object.
362    ///
363    /// # Parameters
364    /// - `packet`: A reference to the TCP packet to analyze.
365    ///
366    /// # Returns
367    /// A `FingerprintResult` object containing the analysis results.
368    pub fn analyze_tcp(&mut self, packet: &[u8]) -> FingerprintResult {
369        match ObservablePackage::extract(
370            packet,
371            &mut self.connection_tracker,
372            &mut self.http_flows,
373            &self.http_processors,
374            &self.config,
375        ) {
376            Ok(observable_package) => {
377                #[cfg(feature = "tcp-mtu")]
378                let mtu: Option<MTUOutput> = observable_package.mtu.map(|observable_mtu| {
379                    let link_quality = self.match_mtu(&observable_mtu.value);
380
381                    MTUOutput {
382                        source: huginn_net_tcp::output::IpPort::new(
383                            observable_package.source.ip,
384                            observable_package.source.port,
385                        ),
386                        destination: huginn_net_tcp::output::IpPort::new(
387                            observable_package.destination.ip,
388                            observable_package.destination.port,
389                        ),
390                        link: link_quality,
391                        mtu: observable_mtu.value,
392                    }
393                });
394
395                #[cfg(feature = "tcp-syn")]
396                let syn: Option<SynTCPOutput> =
397                    observable_package.tcp_request.map(|observable_tcp| {
398                        let os_quality = self.match_tcp_request(&observable_tcp);
399
400                        SynTCPOutput {
401                            source: huginn_net_tcp::output::IpPort::new(
402                                observable_package.source.ip,
403                                observable_package.source.port,
404                            ),
405                            destination: huginn_net_tcp::output::IpPort::new(
406                                observable_package.destination.ip,
407                                observable_package.destination.port,
408                            ),
409                            os_matched: os_quality,
410                            sig: observable_tcp,
411                        }
412                    });
413
414                #[cfg(feature = "tcp-syn-ack")]
415                let syn_ack: Option<SynAckTCPOutput> =
416                    observable_package.tcp_response.map(|observable_tcp| {
417                        let os_quality = self.match_tcp_response(&observable_tcp);
418
419                        SynAckTCPOutput {
420                            source: huginn_net_tcp::output::IpPort::new(
421                                observable_package.source.ip,
422                                observable_package.source.port,
423                            ),
424                            destination: huginn_net_tcp::output::IpPort::new(
425                                observable_package.destination.ip,
426                                observable_package.destination.port,
427                            ),
428                            os_matched: os_quality,
429                            sig: observable_tcp,
430                        }
431                    });
432
433                #[cfg(feature = "tcp-uptime")]
434                let client_uptime: Option<UptimeOutput> =
435                    observable_package.client_uptime.map(|update| UptimeOutput {
436                        source: huginn_net_tcp::output::IpPort::new(
437                            observable_package.source.ip,
438                            observable_package.source.port,
439                        ),
440                        destination: huginn_net_tcp::output::IpPort::new(
441                            observable_package.destination.ip,
442                            observable_package.destination.port,
443                        ),
444                        role: UptimeRole::Client,
445                        days: update.days,
446                        hours: update.hours,
447                        min: update.min,
448                        up_mod_days: update.up_mod_days,
449                        freq: update.freq,
450                    });
451
452                #[cfg(feature = "tcp-uptime")]
453                let server_uptime: Option<UptimeOutput> =
454                    observable_package.server_uptime.map(|update| UptimeOutput {
455                        source: huginn_net_tcp::output::IpPort::new(
456                            observable_package.source.ip,
457                            observable_package.source.port,
458                        ),
459                        destination: huginn_net_tcp::output::IpPort::new(
460                            observable_package.destination.ip,
461                            observable_package.destination.port,
462                        ),
463                        role: UptimeRole::Server,
464                        days: update.days,
465                        hours: update.hours,
466                        min: update.min,
467                        up_mod_days: update.up_mod_days,
468                        freq: update.freq,
469                    });
470
471                #[cfg(feature = "http-p0f-request")]
472                let http_request: Option<HttpRequestOutput> =
473                    observable_package
474                        .http_request
475                        .map(|observable_http_request| {
476                            let HttpRequestMatchResult { browser_quality, http_diagnosis } =
477                                self.match_http_request(&observable_http_request);
478
479                            HttpRequestOutput {
480                                source: huginn_net_http::output::IpPort::new(
481                                    observable_package.source.ip,
482                                    observable_package.source.port,
483                                ),
484                                destination: huginn_net_http::output::IpPort::new(
485                                    observable_package.destination.ip,
486                                    observable_package.destination.port,
487                                ),
488                                lang: observable_http_request.lang.clone(),
489                                browser_matched: browser_quality,
490                                diagnosis: http_diagnosis,
491                                sig: observable_http_request,
492                            }
493                        });
494
495                #[cfg(feature = "http-p0f-response")]
496                let http_response: Option<HttpResponseOutput> = observable_package
497                    .http_response
498                    .map(|observable_http_response| {
499                        let web_server_quality =
500                            self.match_http_response(&observable_http_response);
501
502                        HttpResponseOutput {
503                            source: huginn_net_http::output::IpPort::new(
504                                observable_package.source.ip,
505                                observable_package.source.port,
506                            ),
507                            destination: huginn_net_http::output::IpPort::new(
508                                observable_package.destination.ip,
509                                observable_package.destination.port,
510                            ),
511                            web_server_matched: web_server_quality,
512                            diagnosis: HttpDiagnosis::None,
513                            sig: observable_http_response,
514                        }
515                    });
516
517                let tls_client: Option<TlsClientOutput> =
518                    observable_package
519                        .tls_client
520                        .map(|observable_tls| TlsClientOutput {
521                            source: huginn_net_tls::output::IpPort::new(
522                                observable_package.source.ip,
523                                observable_package.source.port,
524                            ),
525                            destination: huginn_net_tls::output::IpPort::new(
526                                observable_package.destination.ip,
527                                observable_package.destination.port,
528                            ),
529                            sig: observable_tls,
530                        });
531
532                FingerprintResult {
533                    #[cfg(feature = "tcp-syn")]
534                    tcp_syn: syn,
535                    #[cfg(feature = "tcp-syn-ack")]
536                    tcp_syn_ack: syn_ack,
537                    #[cfg(feature = "tcp-mtu")]
538                    tcp_mtu: mtu,
539                    #[cfg(feature = "tcp-uptime")]
540                    tcp_client_uptime: client_uptime,
541                    #[cfg(feature = "tcp-uptime")]
542                    tcp_server_uptime: server_uptime,
543                    #[cfg(feature = "http-p0f-request")]
544                    http_request,
545                    #[cfg(feature = "http-p0f-response")]
546                    http_response,
547                    tls_client,
548                }
549            }
550            Err(error) => {
551                debug!("Fail to process signature: {}", error);
552                FingerprintResult {
553                    #[cfg(feature = "tcp-syn")]
554                    tcp_syn: None,
555                    #[cfg(feature = "tcp-syn-ack")]
556                    tcp_syn_ack: None,
557                    #[cfg(feature = "tcp-mtu")]
558                    tcp_mtu: None,
559                    #[cfg(feature = "tcp-uptime")]
560                    tcp_client_uptime: None,
561                    #[cfg(feature = "tcp-uptime")]
562                    tcp_server_uptime: None,
563                    #[cfg(feature = "http-p0f-request")]
564                    http_request: None,
565                    #[cfg(feature = "http-p0f-response")]
566                    http_response: None,
567                    tls_client: None,
568                }
569            }
570        }
571    }
572}