redhound 1.0.1 → 2.0.0.rc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +97 -31
- data/docs/API.md +104 -0
- data/docs/FILTERS.md +37 -0
- data/docs/MIGRATION.md +23 -0
- data/docs/PROTOCOLS.md +54 -0
- data/docs/USAGE.md +86 -0
- data/docs/VALIDATION.md +175 -0
- data/docs/json-schema.json +64 -0
- data/exe/redhound +2 -1
- data/lib/redhound/analysis/flow_key.rb +59 -0
- data/lib/redhound/analysis/flow_table.rb +124 -0
- data/lib/redhound/analysis/ip_reassembler.rb +209 -0
- data/lib/redhound/analysis/stats.rb +150 -0
- data/lib/redhound/analysis/tcp_analysis.rb +43 -0
- data/lib/redhound/analysis/tcp_reassembler.rb +176 -0
- data/lib/redhound/analysis/tcp_stream.rb +156 -0
- data/lib/redhound/analysis.rb +157 -0
- data/lib/redhound/capture/bsd/bpf_device.rb +155 -0
- data/lib/redhound/capture/bsd/constants.rb +29 -0
- data/lib/redhound/capture/bsd/ifreq.rb +41 -0
- data/lib/redhound/capture/file_source.rb +214 -0
- data/lib/redhound/capture/interface.rb +70 -0
- data/lib/redhound/capture/linktype.rb +30 -0
- data/lib/redhound/capture/linux/constants.rb +37 -0
- data/lib/redhound/capture/linux/cooked.rb +18 -0
- data/lib/redhound/capture/linux/ifreq.rb +42 -0
- data/lib/redhound/capture/linux/packet_socket.rb +224 -0
- data/lib/redhound/capture/linux/sockaddr_ll.rb +33 -0
- data/lib/redhound/capture/linux/tpacket_v3.rb +137 -0
- data/lib/redhound/capture/source.rb +79 -0
- data/lib/redhound/capture/stats.rb +22 -0
- data/lib/redhound/capture.rb +70 -0
- data/lib/redhound/cli/command.rb +42 -0
- data/lib/redhound/cli/options.rb +133 -0
- data/lib/redhound/cli/privileges.rb +29 -0
- data/lib/redhound/cli/runner.rb +129 -0
- data/lib/redhound/context.rb +20 -0
- data/lib/redhound/cursor.rb +69 -0
- data/lib/redhound/diagnostic.rb +7 -0
- data/lib/redhound/dissector.rb +152 -0
- data/lib/redhound/engine.rb +69 -0
- data/lib/redhound/errors.rb +38 -0
- data/lib/redhound/field.rb +62 -0
- data/lib/redhound/file/format.rb +63 -0
- data/lib/redhound/file/pcap_reader.rb +43 -0
- data/lib/redhound/file/pcap_writer.rb +79 -0
- data/lib/redhound/file/pcapng_reader.rb +221 -0
- data/lib/redhound/file/pcapng_writer.rb +151 -0
- data/lib/redhound/file/rotating_writer.rb +98 -0
- data/lib/redhound/filter/analyzer.rb +197 -0
- data/lib/redhound/filter/bpf/assembler.rb +121 -0
- data/lib/redhound/filter/bpf/disassembler.rb +58 -0
- data/lib/redhound/filter/bpf/validator.rb +77 -0
- data/lib/redhound/filter/bpf/vm.rb +109 -0
- data/lib/redhound/filter/codegen.rb +413 -0
- data/lib/redhound/filter/lexer.rb +46 -0
- data/lib/redhound/filter/parser.rb +206 -0
- data/lib/redhound/filter/program.rb +55 -0
- data/lib/redhound/filter.rb +30 -0
- data/lib/redhound/layer.rb +75 -0
- data/lib/redhound/output/fields.rb +20 -0
- data/lib/redhound/output/follow.rb +64 -0
- data/lib/redhound/output/hexdump.rb +29 -0
- data/lib/redhound/output/json.rb +32 -0
- data/lib/redhound/output/summary.rb +84 -0
- data/lib/redhound/output/timestamp.rb +34 -0
- data/lib/redhound/output/tree.rb +25 -0
- data/lib/redhound/packet.rb +77 -0
- data/lib/redhound/protocols/arp.rb +39 -0
- data/lib/redhound/protocols/checksum.rb +38 -0
- data/lib/redhound/protocols/data.rb +23 -0
- data/lib/redhound/protocols/dhcp.rb +140 -0
- data/lib/redhound/protocols/dns.rb +268 -0
- data/lib/redhound/protocols/ethernet.rb +24 -0
- data/lib/redhound/protocols/http.rb +185 -0
- data/lib/redhound/protocols/icmp.rb +205 -0
- data/lib/redhound/protocols/ipv4.rb +56 -0
- data/lib/redhound/protocols/ipv6.rb +68 -0
- data/lib/redhound/protocols/link.rb +70 -0
- data/lib/redhound/protocols/llc.rb +37 -0
- data/lib/redhound/protocols/ntp.rb +45 -0
- data/lib/redhound/protocols/tcp.rb +88 -0
- data/lib/redhound/protocols/tls.rb +218 -0
- data/lib/redhound/protocols/tunnel.rb +48 -0
- data/lib/redhound/protocols/udp.rb +32 -0
- data/lib/redhound/protocols/vlan.rb +23 -0
- data/lib/redhound/reader.rb +8 -0
- data/lib/redhound/registry.rb +64 -0
- data/lib/redhound/stream_dissector.rb +168 -0
- data/lib/redhound/util/seq.rb +21 -0
- data/lib/redhound/version.rb +2 -1
- data/lib/redhound/writer.rb +25 -43
- data/lib/redhound.rb +81 -9
- data/sig/generated/redhound/analysis/flow_key.rbs +15 -0
- data/sig/generated/redhound/analysis/flow_table.rbs +102 -0
- data/sig/generated/redhound/analysis/ip_reassembler.rbs +52 -0
- data/sig/generated/redhound/analysis/stats.rbs +80 -0
- data/sig/generated/redhound/analysis/tcp_analysis.rbs +15 -0
- data/sig/generated/redhound/analysis/tcp_reassembler.rbs +39 -0
- data/sig/generated/redhound/analysis/tcp_stream.rbs +60 -0
- data/sig/generated/redhound/analysis.rbs +38 -0
- data/sig/generated/redhound/capture/bsd/bpf_device.rbs +39 -0
- data/sig/generated/redhound/capture/bsd/constants.rbs +40 -0
- data/sig/generated/redhound/capture/bsd/ifreq.rbs +15 -0
- data/sig/generated/redhound/capture/file_source.rbs +67 -0
- data/sig/generated/redhound/capture/interface.rbs +53 -0
- data/sig/generated/redhound/capture/linktype.rbs +20 -0
- data/sig/generated/redhound/capture/linux/constants.rbs +58 -0
- data/sig/generated/redhound/capture/linux/cooked.rbs +15 -0
- data/sig/generated/redhound/capture/linux/ifreq.rbs +18 -0
- data/sig/generated/redhound/capture/linux/packet_socket.rbs +64 -0
- data/sig/generated/redhound/capture/linux/sockaddr_ll.rbs +31 -0
- data/sig/generated/redhound/capture/linux/tpacket_v3.rbs +35 -0
- data/sig/generated/redhound/capture/source.rbs +44 -0
- data/sig/generated/redhound/capture/stats.rbs +25 -0
- data/sig/generated/redhound/capture.rbs +14 -0
- data/sig/generated/redhound/cli/command.rbs +12 -0
- data/sig/generated/redhound/cli/options.rbs +25 -0
- data/sig/generated/redhound/cli/privileges.rbs +12 -0
- data/sig/generated/redhound/cli/runner.rbs +27 -0
- data/sig/generated/redhound/context.rbs +27 -0
- data/sig/generated/redhound/cursor.rbs +59 -0
- data/sig/generated/redhound/diagnostic.rbs +21 -0
- data/sig/generated/redhound/dissector.rbs +117 -0
- data/sig/generated/redhound/engine.rbs +23 -0
- data/sig/generated/redhound/errors.rbs +56 -0
- data/sig/generated/redhound/field.rbs +74 -0
- data/sig/generated/redhound/file/format.rbs +39 -0
- data/sig/generated/redhound/file/pcap_reader.rbs +19 -0
- data/sig/generated/redhound/file/pcap_writer.rbs +38 -0
- data/sig/generated/redhound/file/pcapng_reader.rbs +54 -0
- data/sig/generated/redhound/file/pcapng_writer.rbs +50 -0
- data/sig/generated/redhound/file/rotating_writer.rbs +42 -0
- data/sig/generated/redhound/filter/analyzer.rbs +42 -0
- data/sig/generated/redhound/filter/bpf/assembler.rbs +39 -0
- data/sig/generated/redhound/filter/bpf/disassembler.rbs +18 -0
- data/sig/generated/redhound/filter/bpf/validator.rbs +30 -0
- data/sig/generated/redhound/filter/bpf/vm.rbs +28 -0
- data/sig/generated/redhound/filter/codegen.rbs +101 -0
- data/sig/generated/redhound/filter/lexer.rbs +15 -0
- data/sig/generated/redhound/filter/parser.rbs +65 -0
- data/sig/generated/redhound/filter/program.rbs +48 -0
- data/sig/generated/redhound/filter.rbs +10 -0
- data/sig/generated/redhound/layer.rbs +81 -0
- data/sig/generated/redhound/output/fields.rbs +15 -0
- data/sig/generated/redhound/output/follow.rbs +21 -0
- data/sig/generated/redhound/output/hexdump.rbs +15 -0
- data/sig/generated/redhound/output/json.rbs +18 -0
- data/sig/generated/redhound/output/summary.rbs +21 -0
- data/sig/generated/redhound/output/timestamp.rbs +21 -0
- data/sig/generated/redhound/output/tree.rbs +15 -0
- data/sig/generated/redhound/packet.rbs +82 -0
- data/sig/generated/redhound/protocols/arp.rbs +15 -0
- data/sig/generated/redhound/protocols/checksum.rbs +15 -0
- data/sig/generated/redhound/protocols/data.rbs +17 -0
- data/sig/generated/redhound/protocols/dhcp.rbs +31 -0
- data/sig/generated/redhound/protocols/dns.rbs +42 -0
- data/sig/generated/redhound/protocols/ethernet.rbs +15 -0
- data/sig/generated/redhound/protocols/http.rbs +36 -0
- data/sig/generated/redhound/protocols/icmp.rbs +41 -0
- data/sig/generated/redhound/protocols/ipv4.rbs +15 -0
- data/sig/generated/redhound/protocols/ipv6.rbs +24 -0
- data/sig/generated/redhound/protocols/link.rbs +33 -0
- data/sig/generated/redhound/protocols/llc.rbs +15 -0
- data/sig/generated/redhound/protocols/ntp.rbs +17 -0
- data/sig/generated/redhound/protocols/tcp.rbs +25 -0
- data/sig/generated/redhound/protocols/tls.rbs +51 -0
- data/sig/generated/redhound/protocols/tunnel.rbs +24 -0
- data/sig/generated/redhound/protocols/udp.rbs +18 -0
- data/sig/generated/redhound/protocols/vlan.rbs +12 -0
- data/sig/generated/redhound/reader.rbs +7 -0
- data/sig/generated/redhound/registry.rbs +45 -0
- data/sig/generated/redhound/stream_dissector.rbs +44 -0
- data/sig/generated/redhound/util/seq.rbs +20 -0
- data/sig/generated/redhound/version.rbs +1 -0
- data/sig/generated/redhound/writer.rbs +6 -19
- data/sig/generated/redhound.rbs +18 -0
- data/sig/shims/io.rbs +7 -0
- data/sig/shims/process.rbs +3 -0
- metadata +177 -55
- data/CODE_OF_CONDUCT.md +0 -132
- data/Dockerfile +0 -7
- data/Rakefile +0 -15
- data/Steepfile +0 -4
- data/docker-compose.yml +0 -9
- data/lib/redhound/analyzer.rb +0 -34
- data/lib/redhound/builder/packet_mreq.rb +0 -56
- data/lib/redhound/builder/socket.rb +0 -35
- data/lib/redhound/builder.rb +0 -4
- data/lib/redhound/command.rb +0 -67
- data/lib/redhound/l2/ether.rb +0 -68
- data/lib/redhound/l2/protocol.rb +0 -33
- data/lib/redhound/l2.rb +0 -4
- data/lib/redhound/l3/arp.rb +0 -114
- data/lib/redhound/l3/base.rb +0 -49
- data/lib/redhound/l3/ipv4.rb +0 -95
- data/lib/redhound/l3/ipv6.rb +0 -69
- data/lib/redhound/l3/protocol.rb +0 -173
- data/lib/redhound/l3/resolver.rb +0 -30
- data/lib/redhound/l3.rb +0 -9
- data/lib/redhound/l4/base.rb +0 -31
- data/lib/redhound/l4/icmp.rb +0 -71
- data/lib/redhound/l4/resolver.rb +0 -28
- data/lib/redhound/l4/udp.rb +0 -63
- data/lib/redhound/l4.rb +0 -6
- data/lib/redhound/receiver.rb +0 -45
- data/lib/redhound/resolver.rb +0 -22
- data/lib/redhound/source/socket.rb +0 -18
- data/lib/redhound/source.rb +0 -3
- data/rbs_collection.lock.yaml +0 -20
- data/rbs_collection.yaml +0 -17
- data/sig/generated/redhound/analyzer.rbs +0 -14
- data/sig/generated/redhound/builder/packet_mreq.rbs +0 -39
- data/sig/generated/redhound/builder/socket.rbs +0 -24
- data/sig/generated/redhound/command.rbs +0 -19
- data/sig/generated/redhound/l2/ether.rbs +0 -41
- data/sig/generated/redhound/l2/protocol.rbs +0 -15
- data/sig/generated/redhound/l3/arp.rbs +0 -57
- data/sig/generated/redhound/l3/base.rbs +0 -28
- data/sig/generated/redhound/l3/ipv4.rbs +0 -53
- data/sig/generated/redhound/l3/ipv6.rbs +0 -38
- data/sig/generated/redhound/l3/protocol.rbs +0 -16
- data/sig/generated/redhound/l3/resolver.rbs +0 -16
- data/sig/generated/redhound/l4/base.rbs +0 -19
- data/sig/generated/redhound/l4/icmp.rbs +0 -33
- data/sig/generated/redhound/l4/resolver.rbs +0 -16
- data/sig/generated/redhound/l4/udp.rbs +0 -36
- data/sig/generated/redhound/receiver.rbs +0 -19
- data/sig/generated/redhound/resolver.rbs +0 -11
- data/sig/generated/redhound/source/socket.rbs +0 -13
data/docs/VALIDATION.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Release validation
|
|
2
|
+
|
|
3
|
+
## Adversarial review after rc1
|
|
4
|
+
|
|
5
|
+
The review checked the design, roadmap, work procedures and phase0 patch against
|
|
6
|
+
the running implementation, rather than treating a green test suite as proof
|
|
7
|
+
that every acceptance gate had passed. Reproduced failures included truncated
|
|
8
|
+
headers losing readable fields, incorrect ICMP error fields and dynamic field
|
|
9
|
+
locations, missed split HTTP identification, unreported reassembly loss at EOF
|
|
10
|
+
and flow eviction, underestimated Ruby buffer capacity, unbounded pcapng
|
|
11
|
+
metadata, source timeout violations and capture writer descriptor leaks.
|
|
12
|
+
Regression checks accompany the fixes.
|
|
13
|
+
|
|
14
|
+
The parser fuzz now retains each fixture's real link type during structured
|
|
15
|
+
mutations. This prevents tests from stopping at an unrelated link header before
|
|
16
|
+
reaching the mutated protocol. `ruby bench/analysis.rb` exercises default state
|
|
17
|
+
limits with 60,000 conversations, 40,000 fragmented datagram identities and 80
|
|
18
|
+
large TCP streams, checking every update and final buffer release. Its retained
|
|
19
|
+
state accounting includes String capacity and object storage. Process RSS is
|
|
20
|
+
reported separately: Ruby may retain allocator pages after state is released.
|
|
21
|
+
|
|
22
|
+
The manual [Live acceptance workflow](../.github/workflows/acceptance.yml) and
|
|
23
|
+
`bench/live.rb` record requested/sent/captured counts, gaps, byte digests, kernel
|
|
24
|
+
timestamps, drops, RSS and descriptor counts. An insufficient sender rate fails
|
|
25
|
+
the check. Controlled aarch64 runs passed 50,000 pps with simultaneous socket and
|
|
26
|
+
ring capture and 80,000 pps with socket capture for ten seconds. These short
|
|
27
|
+
checks do not replace the hour-long comparison or the x86_64 targets.
|
|
28
|
+
The strengthened live harness checks complete expected wire bytes, every
|
|
29
|
+
timestamp and the entire zero-based sequence. Its mixed TCP/UDP workload and
|
|
30
|
+
capture-only CPU affinity are recorded alongside implementation and harness
|
|
31
|
+
digests. The earlier long-running snapshot uses the original UDP workload and
|
|
32
|
+
verifier; its comparison requires identical complete-frame digests and sampled
|
|
33
|
+
timestamps, and does not establish the new mixed single-core throughput gate.
|
|
34
|
+
|
|
35
|
+
Local adversarial runtime validation on 2026-10-02 passed all 244 non-live examples
|
|
36
|
+
on Linux, with only the macOS-specific interface test skipped. After adding
|
|
37
|
+
bounded file prefetch and live-harness regressions, the complete macOS Rake task
|
|
38
|
+
passed its 257 examples (20 tshark comparisons are run on Linux), RBS generation
|
|
39
|
+
and Steep, with 92.92% total line coverage. Coverage gates passed; the earlier
|
|
40
|
+
Linux check measured 92.43% total line coverage. The strengthened one-million-case parser fuzz completed without an
|
|
41
|
+
exception. Default state-pressure checks reached exactly 256 MiB accounted
|
|
42
|
+
state without exceeding it and released IP/TCP buffers at EOF. Native RSS
|
|
43
|
+
reached 308 MiB despite retaining only 107 MiB of Ruby heap before finalization;
|
|
44
|
+
this is not reported as a 256 MiB process-RSS guarantee.
|
|
45
|
+
The independent [manual CI and million-case fuzz run](https://github.com/ydah/redhound/actions/runs/36885293639)
|
|
46
|
+
also passed every job on the adversarial runtime fixes.
|
|
47
|
+
|
|
48
|
+
The GitHub maintenance configuration now includes weekly Bundler/Actions
|
|
49
|
+
dependency updates, actionlint, codespell, strict yamllint, zizmor and Ruby/Actions
|
|
50
|
+
CodeQL analysis. Action revisions are pinned and checkout credentials are not
|
|
51
|
+
retained. All four lint workflows passed on main; the final CodeQL run succeeded
|
|
52
|
+
with no open alerts. HTTP header trimming uses
|
|
53
|
+
bounded byte ranges while preserving SP/HTAB-only whitespace semantics; controls
|
|
54
|
+
remain visible to malformed-header diagnostics.
|
|
55
|
+
After this final parser change, all 16 application-boundary/live-harness examples,
|
|
56
|
+
the one-million-case native fuzz run (94.25 seconds), and Steep passed.
|
|
57
|
+
All 5,013 binary boundary/random inputs preserved the previous trimming bytes
|
|
58
|
+
and encoding. Linux tcpdump/tshark comparisons passed all 26 examples.
|
|
59
|
+
|
|
60
|
+
Ruby 4.0 introduced an additional restriction in
|
|
61
|
+
[`IO::Buffer.map`](https://docs.ruby-lang.org/en/4.0/IO/Buffer.html#method-c-map):
|
|
62
|
+
it rejects files with zero size even when a mapping size is supplied. Linux
|
|
63
|
+
AF_PACKET sockets have zero file size, so the pure-Ruby ring backend cannot map
|
|
64
|
+
them on that runtime. Ruby 3.3/3.4 ring support remains available; Ruby 4.0 uses
|
|
65
|
+
the socket backend. No C extension or Fiddle workaround is introduced. Ring
|
|
66
|
+
availability on Ruby 4.0 remains an upstream API constraint on full acceptance.
|
|
67
|
+
|
|
68
|
+
Long-running controlled tests started at 2026-10-01 14:55:57 UTC with a frozen
|
|
69
|
+
capture implementation digest
|
|
70
|
+
`084591da70d195146b37aff76de7ec9495085d045684decc26da6c34c4ebc20e`:
|
|
71
|
+
|
|
72
|
+
| Check | Requested workload | Evidence directory | Earliest completion |
|
|
73
|
+
| --- | --- | --- | --- |
|
|
74
|
+
| aarch64 socket/ring comparison | 50,000 pps, 3,600 seconds | `tmp/acceptance-arm-hour-final` | 2026-10-01 15:55:58 UTC |
|
|
75
|
+
| Bounded ring pcapng rotation | 100 pps, 259,200 seconds | `tmp/acceptance-rotation-72h-final` | 2026-10-04 14:55:58 UTC |
|
|
76
|
+
|
|
77
|
+
These directories contain local private captures and are excluded from Git.
|
|
78
|
+
Completion requires the final `result.json` to pass, inspection of the periodic
|
|
79
|
+
resource samples, and successful external validation of rotated files. The
|
|
80
|
+
controlled veth soak does not establish the separate busy physical-interface
|
|
81
|
+
24-hour gate. Results and unfulfilled conditions are recorded explicitly below.
|
|
82
|
+
|
|
83
|
+
The frozen rotation run first reported 1,178 kernel drops and three ring freezes
|
|
84
|
+
at the 4,200-second sample, after zero drops at 3,600 seconds. The macOS power log
|
|
85
|
+
records hibernation/wake in that interval with a 10.614-second recovery delay.
|
|
86
|
+
This correlation does not isolate the cause, and the run is not loss-free
|
|
87
|
+
acceptance evidence. Its periodic resource observations are retained. External
|
|
88
|
+
capinfos/tshark inspection now runs after capture closes, validating the three
|
|
89
|
+
retained rotation files rather than blocking packet reads at every rotation.
|
|
90
|
+
A four-second rotation smoke captured and verified all 4,000 frames without
|
|
91
|
+
drops, gaps or malformed records and passed both external tools.
|
|
92
|
+
|
|
93
|
+
A fresh frozen-source rotation run started at 2026-10-01 16:22:04 UTC, using
|
|
94
|
+
capture digest
|
|
95
|
+
`7fd49cc4c24664c298c99dbf35276f64e67a1195093c3e1c4f9190fa9d234014`
|
|
96
|
+
and harness digest
|
|
97
|
+
`55b36abe852549836350d51ea9994b71ded5409794d853777f0634ca6830c760`.
|
|
98
|
+
Its private evidence is in `tmp/acceptance-rotation-72h-rc2`; completion cannot
|
|
99
|
+
be checked before 2026-10-04 16:22:06 UTC. macOS idle/system sleep assertions
|
|
100
|
+
were confirmed on AC power with a 72-hour-plus-ten-minute `caffeinate` timer.
|
|
101
|
+
This run is still in progress and does not count as completed acceptance.
|
|
102
|
+
|
|
103
|
+
The one-hour aarch64 comparison completed successfully: each backend captured
|
|
104
|
+
all 180,000,000 frames with zero drops/gaps and the same complete-byte digest.
|
|
105
|
+
All 18,000 paired timestamp samples were identical. Ring final drain was
|
|
106
|
+
47.638 ms; descriptor counts remained 10/11. Last-half-hour RSS ranged from
|
|
107
|
+
31.2–40.2 MiB for sockets and 86.5–86.6 MiB for rings. P6-06 is complete, so
|
|
108
|
+
aarch64/arm64 Linux now tries ring capture automatically, retaining socket
|
|
109
|
+
fallback when mapping is unavailable. This does not complete the separate
|
|
110
|
+
72-hour rotation soak or the mixed x86_64 throughput gates.
|
|
111
|
+
|
|
112
|
+
The physical macOS en0 interface is active, but `/dev/bpf*` requires administrator
|
|
113
|
+
access and `sudo -n` requires a password in this environment. The user could not
|
|
114
|
+
start the privileged run, so the 24-hour physical-interface check has not begun.
|
|
115
|
+
An independent three-second observation measured approximately 82 packets/s and
|
|
116
|
+
21 kB/s; it does not establish a busy workload. `bench/soak.rb` is ready to record
|
|
117
|
+
24-hour elapsed time, bounded private pcapng rotation, traffic rates, RSS and
|
|
118
|
+
descriptor counts once capture privileges are available. Its one-second Linux
|
|
119
|
+
smoke and interrupted-run checks passed, including capinfos/tshark file checks.
|
|
120
|
+
|
|
121
|
+
## 2.0.0.rc1
|
|
122
|
+
|
|
123
|
+
The release candidate implements the functional parts of the v2 roadmap: the packet/dissector API, network and
|
|
124
|
+
application protocols, Ruby cBPF compiler/VM, Linux socket/ring and macOS BPF
|
|
125
|
+
backends, capture files/rotation/privilege drop, bounded stateful analysis,
|
|
126
|
+
statistics, follow output and documentation. This is a release candidate,
|
|
127
|
+
not a claim that time-dependent GA acceptance checks have completed.
|
|
128
|
+
|
|
129
|
+
Reproducible checks:
|
|
130
|
+
|
|
131
|
+
- `bundle exec rake`: RSpec, generated RBS and Steep.
|
|
132
|
+
- `REDHOUND_COVERAGE_GATE=1 bundle exec rake`: at least 90% total line coverage
|
|
133
|
+
and 95% protocol line coverage.
|
|
134
|
+
- `bundle exec rspec spec/differential`: tcpdump filter match/bytecode parity
|
|
135
|
+
(163 expressions across five link types), tshark protocol and stateful analysis
|
|
136
|
+
field/statistics/follow comparisons.
|
|
137
|
+
- `FUZZ_ITERATIONS=1000000 bundle exec rspec spec/fuzz`: strict parser fuzzing.
|
|
138
|
+
- `sudo -E env "PATH=$PATH" REDHOUND_LIVE=1 bundle exec rspec --tag live`:
|
|
139
|
+
native capture, attached filters, kernel time, snaplen, stop and loopback.
|
|
140
|
+
- `sudo -E env "PATH=$PATH" REDHOUND_LIVE=1 REDHOUND_NETNS=1 bundle exec rspec spec/integration/netns_capture_spec.rb --tag live`:
|
|
141
|
+
Linux veth socket/ring parity and offloaded VLAN restoration/filtering.
|
|
142
|
+
- `bundle exec yard stats --list-undoc`: documented public API.
|
|
143
|
+
- `actionlint`: test and release workflow validation.
|
|
144
|
+
|
|
145
|
+
Fixtures are generated without runtime dependencies. Golden output freezes
|
|
146
|
+
addresses and timestamps, and validates all JSON snapshots against the schema.
|
|
147
|
+
The CI matrix checks Ruby 3.3/3.4/4.0/head, Linux/macOS live capture and tool
|
|
148
|
+
comparisons. Scheduled runs execute one million fuzz cases. Benchmark jobs warn
|
|
149
|
+
on a summary throughput regression over 15% compared with the previous commit.
|
|
150
|
+
|
|
151
|
+
Local validation on 2026-10-01: Linux aarch64 Ruby 3.4 passed all 171 non-live
|
|
152
|
+
examples, including tcpdump/tshark differential checks, and five live capture
|
|
153
|
+
examples using socket and ring backends. Native macOS Ruby 4.0 passed unit,
|
|
154
|
+
golden and filter checks; BPF ioctl constants match the installed SDK. Native
|
|
155
|
+
BPF loopback and en0 Ethernet capture, attached filters, timestamps, truncation,
|
|
156
|
+
termination, privilege dropping and pcapng metadata passed the macOS CI job.
|
|
157
|
+
The Linux namespace test passed socket/ring parity and offloaded VLAN checks.
|
|
158
|
+
Ruby 3.3/3.4/4.0/head all passed the CI type, signature and coverage gates.
|
|
159
|
+
Whole-library line coverage measured 90.92%; isolated protocol line coverage
|
|
160
|
+
measured 99.90%. See [benchmarks](../bench/RESULTS.md)
|
|
161
|
+
for throughput figures and measurement limits.
|
|
162
|
+
|
|
163
|
+
## Remaining GA acceptance gates
|
|
164
|
+
|
|
165
|
+
- Run a 24-hour continuous capture stability check.
|
|
166
|
+
- Run a 72-hour rotation soak and record RSS, descriptor counts and drop rates.
|
|
167
|
+
- Confirm x86_64 single-core throughput and live drop targets on the specified
|
|
168
|
+
workload; file benchmarks alone cannot establish loss-free live throughput.
|
|
169
|
+
- Keep rc1 available for two weeks, classify reports, and fix critical/high
|
|
170
|
+
failures before GA.
|
|
171
|
+
|
|
172
|
+
These elapsed-time/hardware gates are not marked complete by unit tests.
|
|
173
|
+
Promote to 2.0.0 only after their evidence is recorded. A 1.x maintenance
|
|
174
|
+
end date is set at GA plus six months; no maintenance branch is created by this
|
|
175
|
+
main-only implementation.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/ydah/redhound/blob/main/docs/json-schema.json",
|
|
4
|
+
"title": "Redhound packet",
|
|
5
|
+
"description": "One packet object emitted by Packet#to_h, NDJSON, or an element of the JSON output array. Nanosecond timestamps and lengths are integers; byte fields use hexadecimal strings; repeated fields use arrays.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["frame", "layers", "diagnostics"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"frame": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["number", "time_epoch_ns", "time", "caplen", "len", "interface", "direction", "linktype"],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"number": { "type": "integer", "minimum": 1 },
|
|
16
|
+
"time_epoch_ns": { "type": "integer" },
|
|
17
|
+
"time": { "type": "string", "description": "UTC calendar time with nine fractional digits; years outside 0000..9999 use expanded signed/long year notation. time_epoch_ns preserves the exact instant.", "pattern": "^-?[0-9]{4,}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{9}Z$" },
|
|
18
|
+
"caplen": { "type": "integer", "minimum": 0 },
|
|
19
|
+
"len": { "type": "integer", "minimum": 0 },
|
|
20
|
+
"interface": { "type": ["string", "null"] },
|
|
21
|
+
"direction": { "enum": ["in", "out", null] },
|
|
22
|
+
"linktype": { "type": "integer", "minimum": 0, "maximum": 65535 }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"layers": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"items": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"required": ["protocol", "offset", "length", "embedded", "fields"],
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"protocol": { "type": "string" },
|
|
33
|
+
"offset": { "type": "integer", "minimum": 0 },
|
|
34
|
+
"length": { "type": "integer", "minimum": 0 },
|
|
35
|
+
"embedded": { "type": "boolean" },
|
|
36
|
+
"fields": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldValue" } }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"diagnostics": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"required": ["layer", "severity", "code", "message", "field"],
|
|
45
|
+
"additionalProperties": false,
|
|
46
|
+
"properties": {
|
|
47
|
+
"layer": { "type": "string" },
|
|
48
|
+
"severity": { "enum": ["note", "warn", "warning", "error"] },
|
|
49
|
+
"code": { "type": "string" },
|
|
50
|
+
"message": { "type": "string" },
|
|
51
|
+
"field": { "type": ["string", "null"] }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"$defs": {
|
|
57
|
+
"fieldValue": {
|
|
58
|
+
"anyOf": [
|
|
59
|
+
{ "type": ["string", "number", "boolean", "null"] },
|
|
60
|
+
{ "type": "array", "items": { "$ref": "#/$defs/fieldValue" } }
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
data/exe/redhound
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Redhound
|
|
5
|
+
# @api private
|
|
6
|
+
module Analysis
|
|
7
|
+
# @api private
|
|
8
|
+
module FlowKey
|
|
9
|
+
# @rbs (Packet packet, ?Symbol? type) -> untyped
|
|
10
|
+
def self.from(packet, type = nil)
|
|
11
|
+
layers = packet.layers.reject(&:embedded)
|
|
12
|
+
if type == :eth
|
|
13
|
+
layer = layers.find { |item| item.protocol == :eth }
|
|
14
|
+
return nil unless layer && layer[:src] && layer[:dst]
|
|
15
|
+
return normalize(:eth, [layer.display(:src), 0], [layer.display(:dst), 0])
|
|
16
|
+
end
|
|
17
|
+
transport = layers.reverse.find { |item| %i[tcp udp icmp icmpv6].include?(item.protocol) }
|
|
18
|
+
if type && %i[tcp udp].include?(type)
|
|
19
|
+
return nil unless transport && transport.protocol == type
|
|
20
|
+
end
|
|
21
|
+
network = if type == :ip || type == :ipv6
|
|
22
|
+
layers.find { |item| item.protocol == (type == :ip ? :ipv4 : :ipv6) }
|
|
23
|
+
elsif transport
|
|
24
|
+
position = layers.index(transport) #: Integer
|
|
25
|
+
layers.take(position).reverse.find { |item| %i[ipv4 ipv6].include?(item.protocol) }
|
|
26
|
+
else
|
|
27
|
+
layers.reverse.find { |item| %i[ipv4 ipv6].include?(item.protocol) }
|
|
28
|
+
end
|
|
29
|
+
return nil unless network && network[:src] && network[:dst]
|
|
30
|
+
proto = type || transport&.protocol
|
|
31
|
+
unless proto
|
|
32
|
+
position = layers.index(network) #: Integer
|
|
33
|
+
extensions = layers.drop(position + 1).take_while { |item| item.protocol == :ipv6_ext }
|
|
34
|
+
number = network.protocol == :ipv4 ? network[:proto] : extensions.last&.[](:next) || network[:nxt]
|
|
35
|
+
proto = "ip_proto_#{number}".to_sym
|
|
36
|
+
end
|
|
37
|
+
if %i[tcp udp].include?(proto)
|
|
38
|
+
return nil unless transport && transport[:srcport] && transport[:dstport]
|
|
39
|
+
ports = [transport[:srcport], transport[:dstport]]
|
|
40
|
+
elsif %i[icmp icmpv6].include?(proto)
|
|
41
|
+
return nil unless transport
|
|
42
|
+
# Echo request/reply share a type series and identifier in both directions.
|
|
43
|
+
series = { 8 => 0, 128 => 129 }.fetch(transport[:type], transport[:type])
|
|
44
|
+
ports = [[series, transport[:ident] || 0], [series, transport[:ident] || 0]]
|
|
45
|
+
else
|
|
46
|
+
ports = [0, 0]
|
|
47
|
+
end
|
|
48
|
+
normalize(proto, [network.display(:src), ports[0]], [network.display(:dst), ports[1]])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs (Symbol proto, untyped src, untyped dst) -> untyped
|
|
52
|
+
def self.normalize(proto, src, dst)
|
|
53
|
+
reverse = (src <=> dst).positive?
|
|
54
|
+
a, b = reverse ? [dst, src] : [src, dst]
|
|
55
|
+
[[proto, *a, *b].freeze, reverse ? 1 : 0]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Redhound
|
|
5
|
+
# @api private
|
|
6
|
+
module Analysis
|
|
7
|
+
# @api private
|
|
8
|
+
class Flow
|
|
9
|
+
BASE_BYTES = 4096
|
|
10
|
+
attr_reader :key, :id, :first_ns, :packets, :bytes, :streams, :applications, :acks, :keep_alives, :http_methods, :probes, :probe_disabled
|
|
11
|
+
attr_accessor :last_ns, :closed_ns, :syn_ns, :syn_direction, :synack_ns, :synack_seq, :initial_rtt, :accounted_bytes
|
|
12
|
+
# @rbs (untyped key, Integer id, Integer timestamp_ns) -> void
|
|
13
|
+
def initialize(key, id, timestamp_ns)
|
|
14
|
+
@key, @id, @first_ns, @last_ns = key, id, timestamp_ns, timestamp_ns
|
|
15
|
+
@packets, @bytes, @streams, @applications, @acks = [0, 0], [0, 0], [nil, nil], [nil, nil], [nil, nil]
|
|
16
|
+
@keep_alives = [false, false]
|
|
17
|
+
@http_methods = [Array.new, Array.new] #: Array[untyped]
|
|
18
|
+
@probes = [Array.new, Array.new] #: Array[untyped]
|
|
19
|
+
@probe_disabled = [false, false]
|
|
20
|
+
@closed_ns = @syn_ns = @syn_direction = @synack_ns = @synack_seq = @initial_rtt = nil # @rbs untyped
|
|
21
|
+
@accounted_bytes = BASE_BYTES
|
|
22
|
+
end
|
|
23
|
+
# @rbs () -> bool
|
|
24
|
+
def closed? = !@closed_ns.nil?
|
|
25
|
+
# @rbs () -> Integer
|
|
26
|
+
def bytesize = BASE_BYTES + @streams.compact.sum(&:bytesize) + @applications.compact.sum(&:bytesize) + @http_methods.sum { |methods| methods.size * 16 } + @probes.sum { |parts| parts.sum { |data, _frame| data.bytesize * 2 + 128 } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @api private
|
|
30
|
+
class FlowTable
|
|
31
|
+
attr_reader :evicted, :expired
|
|
32
|
+
attr_accessor :on_remove
|
|
33
|
+
# @rbs (?max_flows: Integer) -> void
|
|
34
|
+
def initialize(max_flows: 100_000)
|
|
35
|
+
raise ArgumentError, 'max_flows must be positive' unless max_flows.positive?
|
|
36
|
+
@max_flows, @next_id, @evicted, @expired, @clock = max_flows, 0, 0, 0, 0
|
|
37
|
+
@next_sweep, @bytes, @tcp_bytes = 0, 0, 0
|
|
38
|
+
@on_remove = nil # @rbs untyped
|
|
39
|
+
@entries = {} #: Hash[untyped, Flow]
|
|
40
|
+
end
|
|
41
|
+
# @rbs (Packet packet) -> untyped
|
|
42
|
+
def update(packet)
|
|
43
|
+
advance(packet.timestamp_ns)
|
|
44
|
+
result = FlowKey.from(packet)
|
|
45
|
+
return nil unless result
|
|
46
|
+
key, direction = result
|
|
47
|
+
flow = @entries.delete(key)
|
|
48
|
+
transport = packet.layers.reverse.find { |layer| layer.protocol == :tcp && !layer.embedded }
|
|
49
|
+
if flow && transport && transport[:flags] && transport[:flags] & 0x12 == 2
|
|
50
|
+
previous = flow.streams[direction]
|
|
51
|
+
if flow.closed? || (previous && previous.base_seq && previous.base_seq != transport[:seq])
|
|
52
|
+
discard(flow)
|
|
53
|
+
flow = nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
unless flow
|
|
57
|
+
evict if @entries.size >= @max_flows
|
|
58
|
+
flow = Flow.new(key, key[0] == :tcp ? @next_id : -1, @clock)
|
|
59
|
+
@next_id += 1 if key[0] == :tcp
|
|
60
|
+
@bytes += flow.accounted_bytes
|
|
61
|
+
end
|
|
62
|
+
flow.last_ns = @clock
|
|
63
|
+
flow.packets[direction] += 1
|
|
64
|
+
flow.bytes[direction] += packet.original_length
|
|
65
|
+
flow.closed_ns = @clock if transport && transport[:flags] && transport[:flags] & 4 != 0
|
|
66
|
+
@entries[key] = flow
|
|
67
|
+
[flow, direction]
|
|
68
|
+
end
|
|
69
|
+
# @rbs (Integer now_ns) -> void
|
|
70
|
+
def advance(now_ns)
|
|
71
|
+
@clock = [@clock, now_ns].max
|
|
72
|
+
expire(@clock) if @clock >= @next_sweep
|
|
73
|
+
end
|
|
74
|
+
# @rbs (Integer now_ns) -> void
|
|
75
|
+
def expire(now_ns)
|
|
76
|
+
@next_sweep = now_ns + 1_000_000_000
|
|
77
|
+
@entries.delete_if do |_key, flow|
|
|
78
|
+
timeout = flow.closed? ? 10 : flow.key[0] == :tcp ? 120 : 30
|
|
79
|
+
stale = now_ns - flow.last_ns >= timeout * 1_000_000_000
|
|
80
|
+
if stale
|
|
81
|
+
discard(flow)
|
|
82
|
+
@expired += 1
|
|
83
|
+
end
|
|
84
|
+
stale
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
# @rbs (?Symbol? type) -> Flow?
|
|
88
|
+
def evict(type = nil)
|
|
89
|
+
pair = if type
|
|
90
|
+
key = @entries.each_key.find { |item| item[0] == type }
|
|
91
|
+
key ? [key, @entries.delete(key)] : nil
|
|
92
|
+
else
|
|
93
|
+
@entries.shift
|
|
94
|
+
end
|
|
95
|
+
return nil unless pair
|
|
96
|
+
@evicted += 1
|
|
97
|
+
discard(pair[1])
|
|
98
|
+
pair[1]
|
|
99
|
+
end
|
|
100
|
+
# @rbs (Flow flow) -> void
|
|
101
|
+
def discard(flow)
|
|
102
|
+
@bytes -= flow.accounted_bytes
|
|
103
|
+
@tcp_bytes -= flow.accounted_bytes - Flow::BASE_BYTES if flow.key[0] == :tcp
|
|
104
|
+
@on_remove&.call(flow)
|
|
105
|
+
end
|
|
106
|
+
# @rbs (Flow flow) -> void
|
|
107
|
+
def account(flow)
|
|
108
|
+
size = flow.bytesize
|
|
109
|
+
delta = size - flow.accounted_bytes
|
|
110
|
+
@bytes += delta
|
|
111
|
+
@tcp_bytes += delta if flow.key[0] == :tcp
|
|
112
|
+
flow.accounted_bytes = size
|
|
113
|
+
end
|
|
114
|
+
# @rbs () -> Integer
|
|
115
|
+
def size = @entries.size
|
|
116
|
+
# @rbs () -> Array[Flow]
|
|
117
|
+
def values = @entries.values
|
|
118
|
+
# @rbs () -> Integer
|
|
119
|
+
def bytesize = @bytes
|
|
120
|
+
# @rbs () -> Integer
|
|
121
|
+
def tcp_bytesize = @tcp_bytes
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Redhound
|
|
5
|
+
# @api private
|
|
6
|
+
module Analysis
|
|
7
|
+
# @api private
|
|
8
|
+
class IpReassembler
|
|
9
|
+
attr_reader :evicted, :expired
|
|
10
|
+
# @rbs (?max_bytes: Integer, ?timeout_ns: Integer) -> void
|
|
11
|
+
def initialize(max_bytes: 16 << 20, timeout_ns: 30_000_000_000)
|
|
12
|
+
raise ArgumentError, 'fragment limits must be positive' unless max_bytes.positive? && timeout_ns.positive?
|
|
13
|
+
@max_bytes, @timeout_ns, @clock, @evicted, @expired = max_bytes, timeout_ns, 0, 0, 0
|
|
14
|
+
@next_sweep = 0
|
|
15
|
+
@bytes = 0
|
|
16
|
+
@entries, @discarded = {}, {} # @rbs untyped
|
|
17
|
+
end
|
|
18
|
+
# @rbs (Packet packet) -> untyped
|
|
19
|
+
def descriptor(packet)
|
|
20
|
+
layers = packet.layers.reject(&:embedded)
|
|
21
|
+
layers.each_with_index do |layer, index|
|
|
22
|
+
if layer.protocol == :ipv4 && layer[:src] && layer[:dst] && (layer[:mf] == 1 || layer[:frag_offset].to_i.positive?)
|
|
23
|
+
return [layer, layer, [4, layer[:src], layer[:dst], layer[:proto], layer[:id]], layer[:frag_offset], layer[:mf] == 1]
|
|
24
|
+
elsif layer.protocol == :ipv6_ext && layer[:type] == 44 && layer[:fragment_offset] && layer[:identification]
|
|
25
|
+
# RFC 6946: atomic fragments bypass queued fragments and tombstones.
|
|
26
|
+
next if layer[:fragment_offset].zero? && layer[:more] == 0
|
|
27
|
+
network = layers.take(index).reverse.find { |item| item.protocol == :ipv6 }
|
|
28
|
+
return [network, layer, [6, network[:src], network[:dst], layer[:identification]], layer[:fragment_offset], layer[:more] == 1] if network && network[:src] && network[:dst]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
# @rbs (Packet packet) -> Packet?
|
|
34
|
+
def update(packet)
|
|
35
|
+
advance(packet.timestamp_ns)
|
|
36
|
+
info = descriptor(packet)
|
|
37
|
+
return nil unless info
|
|
38
|
+
network, fragment, key, offset, more = info
|
|
39
|
+
return nil if @discarded[key]
|
|
40
|
+
return nil if network.error? || fragment.error?
|
|
41
|
+
length = network.payload_end - fragment.payload_offset
|
|
42
|
+
if offset + length > 65_535 || length.negative? || (more && (length.zero? || length % 8 != 0))
|
|
43
|
+
fragment.diagnose(:error, :malformed, 'invalid IP fragment size or offset')
|
|
44
|
+
remove(key)
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
if network.diagnostics.any? { |diagnostic| diagnostic.code == :truncated }
|
|
48
|
+
fragment.diagnose(:note, :reassembly_gap, 'truncated IP fragment cannot be reassembled')
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
data = packet.data.byteslice(fragment.payload_offset, length) #: String
|
|
52
|
+
state = remove(key) || { parts: Array.new, frames: Array.new, header: nil, next_offset: 6, next: fragment[:next], end: nil } #: untyped
|
|
53
|
+
if key[0] == 6 && state[:next] != fragment[:next]
|
|
54
|
+
fragment.diagnose(:error, :malformed, 'IPv6 fragments have inconsistent next headers')
|
|
55
|
+
return nil
|
|
56
|
+
end
|
|
57
|
+
state[:time] = @clock
|
|
58
|
+
old_parts = state[:parts]
|
|
59
|
+
overlap = old_parts.any? { |pos, bytes| offset < pos + bytes.bytesize && offset + length > pos }
|
|
60
|
+
if overlap
|
|
61
|
+
fragment.diagnose(:warning, :fragment_overlap, 'overlapping IP fragments')
|
|
62
|
+
if key[0] == 6
|
|
63
|
+
@discarded[key] = @clock
|
|
64
|
+
@bytes += 128
|
|
65
|
+
while @discarded.size > [4096, @max_bytes / 128].min
|
|
66
|
+
@discarded.shift
|
|
67
|
+
@bytes -= 128
|
|
68
|
+
@evicted += 1
|
|
69
|
+
end
|
|
70
|
+
while bytesize > @max_bytes && remove_oldest
|
|
71
|
+
@evicted += 1
|
|
72
|
+
end
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
pieces = [[offset, data]]
|
|
77
|
+
old_parts.each do |pos, bytes|
|
|
78
|
+
replacement = [] #: Array[untyped]
|
|
79
|
+
pieces.each do |start, payload|
|
|
80
|
+
ending, old_end = start + payload.bytesize, pos + bytes.bytesize
|
|
81
|
+
if start < old_end && ending > pos
|
|
82
|
+
from, to = [start, pos].max, [ending, old_end].min
|
|
83
|
+
fragment.diagnose(:warning, :overlap_mismatch) if payload.byteslice(from - start, to - from) != bytes.byteslice(from - pos, to - from)
|
|
84
|
+
replacement << [start, payload.byteslice(0, pos - start)] if start < pos
|
|
85
|
+
replacement << [old_end, payload.byteslice(old_end - start..)] if ending > old_end
|
|
86
|
+
else
|
|
87
|
+
replacement << [start, payload]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
pieces = replacement
|
|
91
|
+
end
|
|
92
|
+
first_final = !more && !state[:end]
|
|
93
|
+
unless more
|
|
94
|
+
if state[:end] && state[:end] != offset + length
|
|
95
|
+
fragment.diagnose(:error, :malformed, 'inconsistent final IP fragment length')
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
state[:end] = offset + length
|
|
99
|
+
end
|
|
100
|
+
state[:frames] << packet.number if !pieces.empty? || first_final
|
|
101
|
+
state[:parts].concat(pieces).sort_by!(&:first)
|
|
102
|
+
if offset.zero? && !state[:header]
|
|
103
|
+
state[:header] = packet.data.byteslice(network.offset, fragment.payload_offset - network.offset - (key[0] == 6 ? 8 : 0))
|
|
104
|
+
if key[0] == 6
|
|
105
|
+
position = packet.layers.index(fragment) #: Integer
|
|
106
|
+
previous = packet.layers.take(position).last #: Layer
|
|
107
|
+
state[:next_offset] = previous == network ? 6 : previous.offset - network.offset
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
@entries[key] = state
|
|
111
|
+
@bytes += state_bytesize(state)
|
|
112
|
+
if complete?(state)
|
|
113
|
+
remove(key)
|
|
114
|
+
return virtual_packet(packet, state, key[0])
|
|
115
|
+
end
|
|
116
|
+
while bytesize > @max_bytes
|
|
117
|
+
removed = remove_oldest
|
|
118
|
+
break unless removed
|
|
119
|
+
@evicted += 1
|
|
120
|
+
fragment.diagnose(:warning, :reassembly_gap, 'IP reassembly memory limit exceeded')
|
|
121
|
+
end
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
# @rbs (untyped state) -> bool
|
|
125
|
+
def complete?(state)
|
|
126
|
+
return false unless state[:header] && state[:end]
|
|
127
|
+
expected = 0
|
|
128
|
+
state[:parts].each do |offset, bytes|
|
|
129
|
+
return false unless offset == expected
|
|
130
|
+
expected += bytes.bytesize
|
|
131
|
+
end
|
|
132
|
+
expected == state[:end]
|
|
133
|
+
end
|
|
134
|
+
# @rbs (Packet packet, untyped state, Integer version) -> Packet?
|
|
135
|
+
def virtual_packet(packet, state, version)
|
|
136
|
+
payload = state[:parts].map(&:last).join.b
|
|
137
|
+
header = state[:header].dup
|
|
138
|
+
if header.bytesize + payload.bytesize > 65_535
|
|
139
|
+
packet.layers.last.diagnose(:error, :bad_length, 'reassembled IP datagram exceeds 65535 bytes')
|
|
140
|
+
return nil
|
|
141
|
+
end
|
|
142
|
+
if version == 4
|
|
143
|
+
header[2, 2] = [header.bytesize + payload.bytesize].pack('n')
|
|
144
|
+
header[6, 2] = [header.unpack1('n', offset: 6) & 0x4000].pack('n')
|
|
145
|
+
header[10, 2] = "\0\0"
|
|
146
|
+
header[10, 2] = [Protocols::Checksum.value(header)].pack('n')
|
|
147
|
+
else
|
|
148
|
+
header.setbyte(state[:next_offset], state[:next])
|
|
149
|
+
header[4, 2] = [header.bytesize - 40 + payload.bytesize].pack('n')
|
|
150
|
+
end
|
|
151
|
+
result = Packet.new(header + payload, linktype: 101, timestamp_ns: packet.timestamp_ns, number: packet.number,
|
|
152
|
+
interface: packet.interface, direction: packet.direction,
|
|
153
|
+
meta: packet.meta.merge(reassembled_from: state[:frames].uniq.sort))
|
|
154
|
+
result.engine = packet.engine
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
# @rbs (Integer now_ns) -> void
|
|
158
|
+
def advance(now_ns)
|
|
159
|
+
@clock = [@clock, now_ns].max
|
|
160
|
+
expire(@clock) if @clock >= @next_sweep
|
|
161
|
+
end
|
|
162
|
+
# @rbs (Integer now_ns) -> void
|
|
163
|
+
def expire(now_ns)
|
|
164
|
+
@next_sweep = now_ns + 1_000_000_000
|
|
165
|
+
@entries.delete_if do |_key, state|
|
|
166
|
+
stale = now_ns - state[:time] >= @timeout_ns
|
|
167
|
+
if stale
|
|
168
|
+
@expired += 1
|
|
169
|
+
@bytes -= state_bytesize(state)
|
|
170
|
+
end
|
|
171
|
+
stale
|
|
172
|
+
end
|
|
173
|
+
@discarded.delete_if do |_key, time|
|
|
174
|
+
stale = now_ns - time >= @timeout_ns
|
|
175
|
+
@bytes -= 128 if stale
|
|
176
|
+
stale
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
# @rbs () -> Integer
|
|
180
|
+
def bytesize = @bytes
|
|
181
|
+
# @rbs (untyped state) -> Integer
|
|
182
|
+
def state_bytesize(state)
|
|
183
|
+
512 + (state[:header]&.bytesize || 0) * 2 + state[:frames].size * 32 + state[:parts].sum { |_pos, bytes| bytes.bytesize * 2 + 168 }
|
|
184
|
+
end
|
|
185
|
+
# @rbs (untyped key) -> untyped
|
|
186
|
+
def remove(key)
|
|
187
|
+
state = @entries.delete(key)
|
|
188
|
+
@bytes -= state_bytesize(state) if state
|
|
189
|
+
state
|
|
190
|
+
end
|
|
191
|
+
# @rbs () -> untyped
|
|
192
|
+
def remove_oldest
|
|
193
|
+
pair = @entries.shift
|
|
194
|
+
@bytes -= state_bytesize(pair[1]) if pair
|
|
195
|
+
pair
|
|
196
|
+
end
|
|
197
|
+
# @rbs () -> Integer
|
|
198
|
+
def finish
|
|
199
|
+
incomplete = @entries.size
|
|
200
|
+
@entries.clear
|
|
201
|
+
@discarded.clear
|
|
202
|
+
@bytes = 0
|
|
203
|
+
incomplete
|
|
204
|
+
end
|
|
205
|
+
# @rbs () -> Integer
|
|
206
|
+
def size = @entries.size
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|