Skip to main content

huginn_net_http/http/
ua_os.rs

1//! UA vs observed-OS agreement (p0f `NAT_APP_UA`).
2//!
3//! Free function plus the types the caller needs to inject an observed OS.
4//! No host cache lives here: if the caller can answer, it answers.
5
6use crate::http::UNKNOWN_SOFTWARE;
7use crate::matcher_api::{HttpMatcher, HttpRequestMatch};
8use std::fmt;
9use std::net::IpAddr;
10
11/// OS observed on the network for this connection.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "json", derive(serde::Serialize))]
14pub struct ObservedOs {
15    pub name: String,
16}
17
18/// What the caller satisfies. Mirrors [`HttpMatcher`]: `huginn-net-http`
19/// does not depend on TCP; the caller plugs the source in.
20pub trait ObservedOsSource: Send + Sync {
21    /// Look up the OS seen for this client. Only the client side is required:
22    /// the ephemeral port identifies the connection, which is how the TCP
23    /// side already indexes SYNs.
24    fn observed_os(&self, client: IpAddr, client_port: u16) -> Option<ObservedOs>;
25}
26
27/// How the caller resolved the observed-OS lookup, so
28/// [`UaOsAgreement::NotChecked`] can tell "no provider" from "provider
29/// returned none".
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ObservedOsInput<'a> {
32    /// No [`ObservedOsSource`] was plugged in.
33    NoSource,
34    /// A source was plugged in and returned `None`.
35    Missing,
36    Present(&'a ObservedOs),
37}
38
39/// Why [`check_ua_os_agreement`] did not emit Consistent/Divergent.
40///
41/// Order matches p0f `score_nat`: earlier gates win. A standalone caller
42/// without a provider still sees HTTP-side reasons; [`Self::NoSource`]
43/// only appears when the check would have compared OS names.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "json", derive(serde::Serialize))]
46pub enum NotCheckedReason {
47    NoMatch,
48    NotUserlandApp,
49    NoUserAgent,
50    Dishonest,
51    UaNotInTable,
52    NoSource,
53    NoObservedOs,
54}
55
56impl fmt::Display for NotCheckedReason {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(match self {
59            Self::NoMatch => "no match",
60            Self::NotUserlandApp => "not userland app",
61            Self::NoUserAgent => "no user-agent",
62            Self::Dishonest => "dishonest",
63            Self::UaNotInTable => "ua not in table",
64            Self::NoSource => "no source",
65            Self::NoObservedOs => "no observed os",
66        })
67    }
68}
69
70/// UA-claimed OS vs network-observed OS.
71///
72/// [`Self::Divergent`] is p0f `NAT_APP_UA`: evidence of NAT/proxy, not of a
73/// client lying.
74#[derive(Debug, Clone, PartialEq, Eq)]
75#[cfg_attr(feature = "json", derive(serde::Serialize))]
76pub enum UaOsAgreement {
77    NotChecked(NotCheckedReason),
78    Consistent { os: String },
79    Divergent { ua_os: String, network_os: String },
80}
81
82impl fmt::Display for UaOsAgreement {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::NotChecked(reason) => write!(f, "not checked ({reason})"),
86            Self::Consistent { os } => write!(f, "consistent ({os})"),
87            Self::Divergent { ua_os, network_os } => {
88                write!(f, "divergent (ua={ua_os}, net={network_os})")
89            }
90        }
91    }
92}
93
94fn usable_user_agent(ua: Option<&str>) -> Option<&str> {
95    let ua = ua
96        .map(str::trim)
97        .filter(|s| !s.is_empty() && *s != UNKNOWN_SOFTWARE)?;
98    Some(ua)
99}
100
101pub fn check_ua_os_agreement(
102    ua: Option<&str>,
103    matched: Option<&HttpRequestMatch>,
104    matcher: Option<&dyn HttpMatcher>,
105    observed: ObservedOsInput<'_>,
106) -> UaOsAgreement {
107    let Some(matched) = matched else {
108        return UaOsAgreement::NotChecked(NotCheckedReason::NoMatch);
109    };
110    if matched.browser.family.is_some() {
111        return UaOsAgreement::NotChecked(NotCheckedReason::NotUserlandApp);
112    }
113    let Some(ua) = usable_user_agent(ua) else {
114        return UaOsAgreement::NotChecked(NotCheckedReason::NoUserAgent);
115    };
116    if matched.dishonest {
117        return UaOsAgreement::NotChecked(NotCheckedReason::Dishonest);
118    }
119    let Some(ua_os) = matcher.and_then(|m| m.match_user_agent(ua)) else {
120        return UaOsAgreement::NotChecked(NotCheckedReason::UaNotInTable);
121    };
122    let observed = match observed {
123        ObservedOsInput::NoSource => {
124            return UaOsAgreement::NotChecked(NotCheckedReason::NoSource);
125        }
126        ObservedOsInput::Missing => {
127            return UaOsAgreement::NotChecked(NotCheckedReason::NoObservedOs);
128        }
129        ObservedOsInput::Present(os) => os,
130    };
131    if ua_os.family == observed.name {
132        UaOsAgreement::Consistent { os: ua_os.family }
133    } else {
134        UaOsAgreement::Divergent { ua_os: ua_os.family, network_os: observed.name.clone() }
135    }
136}