netring 0.29.0

High-performance zero-copy packet I/O for Linux (AF_PACKET TPACKET_V3 + AF_XDP)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! TLS fingerprint bundle (0.24 Phase E).
//!
//! [`TlsFingerprint`] gathers the identity-bearing fields of a completed
//! TLS handshake — SNI, ALPN, and the JA3 / JA4 (client) / JA4S (server)
//! fingerprints — plus the flow key, into one struct handed to an
//! [`on_fingerprint`](crate::monitor::MonitorBuilder::on_fingerprint)
//! handler. It's the "who is talking to whom, with what client/server
//! software" view, the unit IOC-matching and asset-inventory code wants.

use crate::protocol::FlowKey;

/// A completed TLS handshake's fingerprint bundle. Built from
/// [`flowscope::tls::TlsHandshake`] + the flow key.
///
/// JA3 + JA4 (client) are royalty-free (BSD) and present with the `tls`
/// feature. `ja4s` (the JA4S **server** fingerprint) is **FoxIO License 1.1**
/// (non-commercial; patent pending) and exists only under the opt-in
/// [`ja4plus`](index.html#features) feature — commercial use requires a FoxIO
/// OEM license (see `docs/FINGERPRINTS.md`). All are `None` if fingerprinting
/// wasn't enabled/configured.
#[cfg(feature = "tls")]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TlsFingerprint {
    /// Server Name Indication from the ClientHello, if present.
    pub sni: Option<String>,
    /// Negotiated ALPN (the server's pick), falling back to the client's
    /// first offered protocol when the server didn't choose one.
    pub alpn: Option<String>,
    /// JA3 client fingerprint (MD5 hex).
    pub ja3: Option<String>,
    /// JA4 client fingerprint (FoxIO format, BSD-licensed).
    pub ja4: Option<String>,
    /// JA4S server fingerprint — **FoxIO License 1.1** (opt-in `ja4plus`
    /// feature; commercial use requires a FoxIO OEM license).
    #[cfg(feature = "ja4plus")]
    pub ja4s: Option<String>,
    /// JA4X fingerprint over the **leaf X.509 certificate** (issuer / subject
    /// / extension OID hashes) — **FoxIO License 1.1** (opt-in `ja4plus`
    /// feature). `None` for TLS 1.3 (the certificate is encrypted) or when the
    /// server didn't present a certificate in the handshake.
    #[cfg(feature = "ja4plus")]
    pub ja4x: Option<String>,
    /// Issue #128: the ClientHello offered a **post-quantum** key-share group
    /// (e.g. X25519MLKEM768 / X25519Kyber768). A cheap PQ-adoption signal for
    /// inventory + policy, independent of the FoxIO fingerprints.
    pub pq_key_share: bool,
    /// Issue #133: the application protocol riding this TLS session, classified
    /// from the negotiated ALPN + SNI + server port (e.g. `Http2`,
    /// `DnsOverHttps`, `DnsOverTls`). `Unknown` when nothing distinctive was
    /// offered.
    pub app_protocol: flowscope::app_proto::AppProtocol,
    /// The flow's 5-tuple key (from the dispatch context), if available.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub key: Option<FlowKey>,
}

#[cfg(feature = "tls")]
impl TlsFingerprint {
    /// Build from a flowscope handshake event + the flow key.
    pub(crate) fn from_handshake(hs: &flowscope::tls::TlsHandshake, key: Option<FlowKey>) -> Self {
        // The server port drives app-proto classification (853 = DoT, etc.);
        // for TLS the server is the destination side of the key. Default 443.
        let port = key.map(|k| k.b.port()).unwrap_or(443);
        Self {
            sni: hs.sni.clone(),
            alpn: hs
                .server_alpn
                .clone()
                .or_else(|| hs.client_alpn.first().cloned()),
            ja3: hs.ja3.clone(),
            ja4: hs.ja4.clone(),
            #[cfg(feature = "ja4plus")]
            ja4s: hs.ja4s.clone(),
            #[cfg(feature = "ja4plus")]
            ja4x: hs.ja4x.clone(),
            pq_key_share: hs.pq_key_share,
            app_protocol: flowscope::app_proto::AppProtocol::from_tls_handshake(hs, port),
            key,
        }
    }

    /// `true` when at least one fingerprint (JA3 / JA4 / JA4S / JA4X) is
    /// present. Cheap guard for handlers that only act on fingerprinted
    /// handshakes.
    pub fn has_fingerprint(&self) -> bool {
        let any = self.ja3.is_some() || self.ja4.is_some();
        #[cfg(feature = "ja4plus")]
        let any = any || self.ja4s.is_some() || self.ja4x.is_some();
        any
    }
}

/// An HTTP request's client fingerprint bundle — the JA4H FoxIO fingerprint
/// plus the identifying request headers — handed to an
/// [`on_http_fingerprint`](crate::monitor::MonitorBuilder::on_http_fingerprint)
/// handler.
///
/// JA4H is **FoxIO License 1.1** (non-commercial; patent pending), so this
/// type and the hook that produces it live behind the opt-in `ja4plus`
/// feature — commercial use requires a FoxIO OEM license (see
/// `docs/FINGERPRINTS.md`). It is the HTTP analogue of [`TlsFingerprint`]:
/// "which client software issued this request", from the method, ordered
/// header names, cookies, and `Accept-Language`.
#[cfg(all(feature = "http", feature = "ja4plus"))]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct HttpFingerprint {
    /// JA4H fingerprint (`a_b_c_d` FoxIO format).
    pub ja4h: String,
    /// Request method (`GET`, `POST`, …), if it was valid ASCII.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub method: Option<String>,
    /// First `Host` header value, if present.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub host: Option<String>,
    /// First `User-Agent` header value, if present.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub user_agent: Option<String>,
    /// The flow's 5-tuple key (from the dispatch context), if available.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub key: Option<FlowKey>,
}

#[cfg(all(feature = "http", feature = "ja4plus"))]
impl HttpFingerprint {
    /// Build from a flowscope HTTP request + the flow key, computing JA4H.
    pub(crate) fn from_request(req: &flowscope::http::HttpRequest, key: Option<FlowKey>) -> Self {
        Self {
            ja4h: flowscope::http::ja4h_fingerprint(req),
            method: req.method_str().map(str::to_owned),
            host: req.host().map(str::to_owned),
            user_agent: req.user_agent().map(str::to_owned),
            key,
        }
    }
}

/// An encrypted-DNS observation (issue #133) — a TLS or QUIC session that
/// classified as DoH / DoT / DoQ — handed to an
/// [`on_encrypted_dns`](crate::monitor::MonitorBuilder::on_encrypted_dns)
/// handler. The passive DNS-visibility gap: once DNS moves inside TLS/QUIC the
/// query names are gone, but the *fact* of encrypted DNS (and to which resolver)
/// is a policy-relevant signal.
#[cfg(feature = "tls")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct EncryptedDns {
    /// Which encrypted-DNS protocol (`DnsOverHttps` / `DnsOverTls` /
    /// `DnsOverQuic`).
    pub app_protocol: flowscope::app_proto::AppProtocol,
    /// The resolver's SNI, if present (e.g. `cloudflare-dns.com`).
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub sni: Option<String>,
    /// `true` when the SNI matches a well-known public DoH/DoT resolver.
    pub via_known_resolver: bool,
    /// The flow's 5-tuple key, if available.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub key: Option<FlowKey>,
    /// Observation timestamp.
    pub ts: flowscope::Timestamp,
}

/// A curated list of well-known public encrypted-DNS resolver hostnames. Not
/// exhaustive — a hit is a strong signal, a miss doesn't rule out DoH/DoT.
#[cfg(feature = "tls")]
const KNOWN_DOH_RESOLVERS: &[&str] = &[
    "cloudflare-dns.com",
    "mozilla.cloudflare-dns.com",
    "one.one.one.one",
    "dns.google",
    "dns.google.com",
    "dns.quad9.net",
    "doh.opendns.com",
    "dns.nextdns.io",
    "doh.cleanbrowsing.org",
    "dns.adguard.com",
    "dns.adguard-dns.com",
    "doh.mullvad.net",
];

/// Whether `sni` (case-insensitive, trailing-dot-insensitive) names a well-known
/// public encrypted-DNS resolver.
#[cfg(feature = "tls")]
pub(crate) fn is_known_doh_resolver(sni: &str) -> bool {
    let s = sni.trim_end_matches('.').to_ascii_lowercase();
    KNOWN_DOH_RESOLVERS.contains(&s.as_str())
}

/// A QUIC client fingerprint bundle (issue #128) — handed to an
/// [`on_quic_fingerprint`](crate::monitor::MonitorBuilder::on_quic_fingerprint)
/// handler once per parsed QUIC Initial. The UDP/QUIC analogue of
/// [`TlsFingerprint`]: SNI + ALPN + version, plus the QUIC-flavoured JA4
/// (`q`-prefixed) and the post-quantum key-share signal when the `tls` feature
/// recovers the embedded ClientHello.
#[cfg(feature = "quic")]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct QuicFingerprint {
    /// Server Name Indication from the Initial's ClientHello, if present.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub sni: Option<String>,
    /// First offered ALPN protocol (e.g. `h3`), if any.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub alpn: Option<String>,
    /// QUIC version label (`v1` / `v2` / a raw `0x…` for others).
    pub version: String,
    /// QUIC JA4 (`q`-prefixed FoxIO format). `None` without the `tls` feature
    /// (the embedded ClientHello is needed) or when it wasn't recoverable.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub ja4: Option<String>,
    /// The Initial's ClientHello offered a post-quantum key-share group. Always
    /// `false` without the `tls` feature (no ClientHello to inspect).
    pub pq_key_share: bool,
    /// Issue #133: the application protocol riding this QUIC session, classified
    /// from ALPN + SNI + server port (e.g. `Http3`, `DnsOverQuic`).
    pub app_protocol: flowscope::app_proto::AppProtocol,
    /// The flow's 5-tuple key, if available.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub key: Option<FlowKey>,
}

#[cfg(feature = "quic")]
impl QuicFingerprint {
    /// Build from a parsed QUIC Initial + the flow key.
    pub(crate) fn from_initial(initial: &flowscope::QuicInitial, key: Option<FlowKey>) -> Self {
        let port = key.map(|k| k.b.port()).unwrap_or(443);
        let version = if initial.version.is_v1() {
            "v1".to_string()
        } else if initial.version.is_v2() {
            "v2".to_string()
        } else {
            format!("0x{:08x}", initial.version.as_u32())
        };
        #[cfg(feature = "tls")]
        let ja4 = flowscope::quic::ja4(initial);
        #[cfg(not(feature = "tls"))]
        let ja4 = None;
        #[cfg(feature = "tls")]
        let pq_key_share = initial
            .client_hello
            .as_ref()
            .is_some_and(|ch| ch.pq_key_share);
        #[cfg(not(feature = "tls"))]
        let pq_key_share = false;
        Self {
            sni: initial.sni.clone(),
            alpn: initial.alpn.first().cloned(),
            version,
            ja4,
            pq_key_share,
            app_protocol: flowscope::app_proto::AppProtocol::from_quic_initial(initial, port),
            key,
        }
    }
}

/// An SSH fingerprint bundle (issue #128) — the HASSH client + server
/// fingerprints, the version banner(s), and the offered KEX algorithms —
/// handed to an
/// [`on_ssh_fingerprint`](crate::monitor::MonitorBuilder::on_ssh_fingerprint)
/// handler once **both** peers' KEXINIT messages have been observed on a flow.
///
/// HASSH is MD5-based and openly specified (Salesforce, BSD-licensed), so unlike
/// the JA4+ fingerprints it needs no `ja4plus` opt-in.
#[cfg(feature = "ssh")]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SshFingerprint {
    /// Version banner line(s) seen on the flow (`SSH-2.0-...`). SSH banners
    /// carry no direction upstream, so both peers' banners land here in
    /// arrival order.
    pub banners: Vec<String>,
    /// HASSH (client KEXINIT fingerprint), if the client KEXINIT was seen.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub hassh: Option<String>,
    /// HASSHServer (server KEXINIT fingerprint), if the server KEXINIT was seen.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub hassh_server: Option<String>,
    /// Client `kex_algorithms` name-list (from the client KEXINIT).
    pub kex_algorithms: Vec<String>,
    /// The flow's 5-tuple key, if available.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
    pub key: Option<FlowKey>,
}

/// Per-flow SSH accumulator (issue #128). Lives in the Monitor's flow-state
/// map (auto-evicted on `FlowEnded`); folds banner + KEXINIT messages until
/// both HASSH fingerprints are known, then fires exactly once.
#[cfg(feature = "ssh")]
#[derive(Debug, Default)]
pub(crate) struct SshFpState {
    pub(crate) banners: Vec<String>,
    pub(crate) hassh: Option<String>,
    pub(crate) hassh_server: Option<String>,
    pub(crate) kex_algorithms: Vec<String>,
    /// Set once the handler has fired, so a duplicate KEXINIT can't re-fire.
    pub(crate) fired: bool,
}

#[cfg(feature = "ssh")]
impl SshFpState {
    /// Fold one SSH message. Returns `Some(fingerprint)` exactly once — when
    /// both peers' KEXINIT have been observed and the handler hasn't fired yet.
    pub(crate) fn observe(
        &mut self,
        msg: &flowscope::ssh::SshMessage,
        key: Option<FlowKey>,
    ) -> Option<SshFingerprint> {
        match msg {
            flowscope::ssh::SshMessage::Banner { banner } => {
                self.banners.push(banner.clone());
            }
            flowscope::ssh::SshMessage::KexInit(k) => {
                if k.from_client {
                    self.hassh.get_or_insert_with(|| k.hassh.clone());
                    if self.kex_algorithms.is_empty() {
                        self.kex_algorithms = k.kex_algorithms.clone();
                    }
                } else {
                    self.hassh_server.get_or_insert_with(|| k.hassh.clone());
                }
            }
            _ => {}
        }
        if !self.fired && self.hassh.is_some() && self.hassh_server.is_some() {
            self.fired = true;
            Some(SshFingerprint {
                banners: self.banners.clone(),
                hassh: self.hassh.clone(),
                hassh_server: self.hassh_server.clone(),
                kex_algorithms: self.kex_algorithms.clone(),
                key,
            })
        } else {
            None
        }
    }
}

#[cfg(all(test, feature = "tls"))]
mod tests {
    use super::*;
    use flowscope::tls::TlsHandshake;

    #[test]
    fn bundles_handshake_fields_and_prefers_server_alpn() {
        // `TlsHandshake` is `#[non_exhaustive]` — build via Default.
        let mut hs = TlsHandshake::default();
        hs.sni = Some("example.com".to_string());
        hs.client_alpn = vec!["h2".to_string(), "http/1.1".to_string()];
        hs.server_alpn = Some("h2".to_string());
        hs.ja3 = Some("abc".to_string());
        hs.ja4 = Some("t13d…".to_string());
        hs.pq_key_share = true;
        #[cfg(feature = "ja4plus")]
        {
            hs.ja4s = Some("t130200_1301_…".to_string());
            hs.ja4x = Some("a564fbbd9b48_5e2c5a8f4f17_8c0e391b6d8b".to_string());
        }

        let fp = TlsFingerprint::from_handshake(&hs, None);
        assert_eq!(fp.sni.as_deref(), Some("example.com"));
        assert_eq!(fp.alpn.as_deref(), Some("h2")); // server's pick
        assert!(fp.pq_key_share, "pq_key_share should carry through (#128)");
        // ALPN h2 on port 443 (default when key absent) → HTTP/2 (#133).
        assert_eq!(
            fp.app_protocol,
            flowscope::app_proto::AppProtocol::Http2,
            "h2 ALPN should classify as Http2"
        );
        #[cfg(feature = "ja4plus")]
        {
            assert_eq!(fp.ja4s.as_deref(), Some("t130200_1301_…"));
            assert_eq!(
                fp.ja4x.as_deref(),
                Some("a564fbbd9b48_5e2c5a8f4f17_8c0e391b6d8b")
            );
        }
        assert!(fp.has_fingerprint());
    }

    #[test]
    fn falls_back_to_first_client_alpn_when_server_did_not_choose() {
        let mut hs = TlsHandshake::default();
        hs.client_alpn = vec!["h2".to_string()];
        hs.server_alpn = None;

        let fp = TlsFingerprint::from_handshake(&hs, None);
        assert_eq!(fp.alpn.as_deref(), Some("h2"));
        assert!(!fp.has_fingerprint());
    }

    #[test]
    fn known_doh_resolver_matching_is_case_and_dot_insensitive() {
        assert!(is_known_doh_resolver("cloudflare-dns.com"));
        assert!(is_known_doh_resolver("Cloudflare-DNS.com."));
        assert!(is_known_doh_resolver("dns.google"));
        assert!(!is_known_doh_resolver("example.com"));
    }

    #[test]
    fn doh_sni_classifies_as_dns_over_https() {
        // h2 ALPN + a known DoH resolver SNI on 443 → DnsOverHttps (#133).
        let mut hs = TlsHandshake::default();
        hs.server_alpn = Some("h2".to_string());
        hs.sni = Some("cloudflare-dns.com".to_string());
        let fp = TlsFingerprint::from_handshake(&hs, None);
        assert_eq!(
            fp.app_protocol,
            flowscope::app_proto::AppProtocol::DnsOverHttps
        );
        assert!(fp.app_protocol.is_encrypted_dns());
    }

    // `on_fingerprint` auto-registers the TlsHandshake protocol and wires
    // the on_ctx handler — assert the resulting monitor builds (cap-free;
    // build() freezes the dispatcher without opening sockets).
    #[cfg(feature = "tokio")]
    #[test]
    fn on_fingerprint_builds_a_valid_monitor_and_doesnt_double_register() {
        // Auto-register path.
        let m = crate::monitor::Monitor::builder()
            .interface("lo")
            .on_fingerprint(|_fp, _ctx| Ok(()))
            .build();
        assert!(m.is_ok(), "auto-register build failed: {:?}", m.err());

        // Explicit `.protocol::<TlsHandshake>()` first → on_fingerprint must
        // not install a second handshake parser.
        let m = crate::monitor::Monitor::builder()
            .interface("lo")
            .protocol::<crate::protocol::builtin::TlsHandshake>()
            .on_fingerprint(|_fp, _ctx| Ok(()))
            .build();
        assert!(m.is_ok(), "explicit-protocol build failed: {:?}", m.err());
    }

    // `on_http_fingerprint` auto-registers the Http protocol and wraps an
    // on_ctx handler that computes JA4H over each request — assert the
    // resulting monitor builds, with and without an explicit
    // `.protocol::<Http>()` first (cap-free; build() doesn't open a socket).
    #[cfg(all(feature = "http", feature = "ja4plus", feature = "tokio"))]
    #[test]
    fn on_http_fingerprint_builds_and_doesnt_double_register() {
        let m = crate::monitor::Monitor::builder()
            .interface("lo")
            .on_http_fingerprint(|_fp, _ctx| Ok(()))
            .build();
        assert!(m.is_ok(), "auto-register build failed: {:?}", m.err());

        let m = crate::monitor::Monitor::builder()
            .interface("lo")
            .protocol::<crate::protocol::builtin::Http>()
            .on_http_fingerprint(|_fp, _ctx| Ok(()))
            .build();
        assert!(m.is_ok(), "explicit-protocol build failed: {:?}", m.err());
    }
}