redhound 2.0.0.rc1 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +1 -1
  4. data/docs/API.md +29 -1
  5. data/docs/MIGRATION.md +1 -1
  6. data/docs/PROTOCOLS.md +10 -1
  7. data/docs/USAGE.md +5 -1
  8. data/docs/VALIDATION.md +119 -3
  9. data/docs/json-schema.json +2 -2
  10. data/lib/redhound/analysis/flow_key.rb +7 -1
  11. data/lib/redhound/analysis/flow_table.rb +12 -5
  12. data/lib/redhound/analysis/ip_reassembler.rb +44 -11
  13. data/lib/redhound/analysis/stats.rb +14 -2
  14. data/lib/redhound/analysis/tcp_reassembler.rb +68 -16
  15. data/lib/redhound/analysis/tcp_stream.rb +7 -2
  16. data/lib/redhound/analysis.rb +30 -2
  17. data/lib/redhound/capture/bsd/bpf_device.rb +3 -0
  18. data/lib/redhound/capture/bsd/ifreq.rb +41 -0
  19. data/lib/redhound/capture/file_source.rb +135 -4
  20. data/lib/redhound/capture/interface.rb +5 -1
  21. data/lib/redhound/capture/linux/packet_socket.rb +53 -9
  22. data/lib/redhound/capture/linux/tpacket_v3.rb +5 -0
  23. data/lib/redhound/capture.rb +3 -2
  24. data/lib/redhound/cli/command.rb +1 -1
  25. data/lib/redhound/dissector.rb +18 -4
  26. data/lib/redhound/engine.rb +3 -0
  27. data/lib/redhound/field.rb +1 -1
  28. data/lib/redhound/file/format.rb +8 -1
  29. data/lib/redhound/file/pcap_writer.rb +10 -3
  30. data/lib/redhound/file/pcapng_reader.rb +19 -7
  31. data/lib/redhound/file/pcapng_writer.rb +22 -14
  32. data/lib/redhound/filter/bpf/vm.rb +3 -2
  33. data/lib/redhound/output/follow.rb +6 -1
  34. data/lib/redhound/output/summary.rb +29 -4
  35. data/lib/redhound/output/timestamp.rb +2 -2
  36. data/lib/redhound/output/tree.rb +2 -1
  37. data/lib/redhound/packet.rb +18 -4
  38. data/lib/redhound/protocols/data.rb +3 -1
  39. data/lib/redhound/protocols/dhcp.rb +29 -13
  40. data/lib/redhound/protocols/http.rb +5 -3
  41. data/lib/redhound/protocols/icmp.rb +55 -41
  42. data/lib/redhound/protocols/ipv6.rb +5 -2
  43. data/lib/redhound/protocols/ntp.rb +2 -4
  44. data/lib/redhound/protocols/tcp.rb +17 -9
  45. data/lib/redhound/protocols/tunnel.rb +2 -2
  46. data/lib/redhound/stream_dissector.rb +2 -2
  47. data/lib/redhound/version.rb +1 -1
  48. data/sig/generated/redhound/analysis/flow_table.rbs +6 -2
  49. data/sig/generated/redhound/analysis/ip_reassembler.rbs +12 -0
  50. data/sig/generated/redhound/analysis/stats.rbs +3 -0
  51. data/sig/generated/redhound/analysis/tcp_reassembler.rbs +12 -0
  52. data/sig/generated/redhound/capture/bsd/ifreq.rbs +15 -0
  53. data/sig/generated/redhound/capture/file_source.rbs +33 -0
  54. data/sig/generated/redhound/capture/linux/packet_socket.rbs +7 -0
  55. data/sig/generated/redhound/dissector.rbs +4 -0
  56. data/sig/generated/redhound/file/format.rbs +6 -0
  57. data/sig/generated/redhound/file/pcapng_reader.rbs +2 -2
  58. data/sig/generated/redhound/filter/bpf/vm.rbs +2 -0
  59. data/sig/generated/redhound/packet.rbs +5 -0
  60. data/sig/generated/redhound/protocols/data.rbs +2 -0
  61. data/sig/generated/redhound/protocols/dhcp.rbs +7 -4
  62. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd9c749a43481a575813ed4bacad00dbc54dfecc7701e63a0baeb6789a1d8680
4
- data.tar.gz: 791efd1f3e810c7e67bcf5abfbbc3e02aa0db460bd8dca51b83beecd94797bae
3
+ metadata.gz: 33f3a971291b9d5c6be138098ab5d6072717b8ffc79f418830f3538d8b9c4500
4
+ data.tar.gz: 191cf625ab53877b304432e4cd0b9caa6725afd1e4929bebaba31ef2382c0108
5
5
  SHA512:
6
- metadata.gz: 99e69fb1319c95448572dcfc2151da08987523b95301c1b50f1f1345b269752cd98e4ddcd3c24f711866bc6b7754793061b51f1d208f92428dfd337a007ac444
7
- data.tar.gz: 1081108559a19f9f17b0c482dc91285f4950b1f7dda81c4f1221e2eac76206831839d265d50139182060c203e12dfd7752e434ecfae2fa5479310260b039fa7f
6
+ metadata.gz: 73fc49a4129a62cfdad32ee818adb4d1915f115f3bbe3f0a47b36d5d8a23c625d275d28a39e8307d4b8d1cc068b0d6be12409e2f99400f0135aa89383d58b0f9
7
+ data.tar.gz: 817303ae395d6f1b288bbab65105736d97f019cfb27dc6ba8edea787d7eab82fed9817d4a3fd0ee36e7e1407af17b48c50adab04412410b36f29044d778f0215
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.0.0.rc2 - 2026-10-02
6
+
7
+ ### Features
8
+
9
+ - Use the ring backend automatically on aarch64 Linux when mapping is available.
10
+
11
+ ### Bug fixes
12
+
13
+ - Improve file reading and packet summary throughput while keeping stdin responsive.
14
+ - Preserve readable fields in truncated headers and correct ICMP errors, IGMP reports, signed NTP precision, IPv6 extension boundaries and variable field locations.
15
+ - Reject malformed TCP options and empty or conflicting HTTP Content-Length values; recognize HTTP when its first line spans segments on nonstandard ports.
16
+ - Report incomplete IP/TCP data at EOF and flow eviction, distinguish retransmitted SYN/FIN packets, and handle IPv6 atomic fragments independently.
17
+ - Account for retained buffer capacity when enforcing reassembly limits, bound pcapng interface metadata, and release analysis buffers even when output fails.
18
+ - Keep capture timeouts and stopping responsive under rejected traffic and idle stdin, preserve partial input records across timeouts, and support replacing file-source filters.
19
+ - Preserve wire lengths with attached filters, close capture files after write failures, and prevent metadata errors from corrupting pcapng interface numbering.
20
+ - Correct macOS interface MAC/MTU and loopback link types; include interface and direction in packet output and show checksum/TCP options with verbose summaries.
21
+ - Preserve negative and expanded-year timestamps and emit valid JSON for invalid UTF-8 diagnostics and interface names; reject invalid packet metadata before file serialization.
22
+ - Include empty intervals in IO statistics and keep unknown IP protocol conversations separate.
23
+ - Fall back to socket capture when Ruby 4.0 cannot map a packet ring, and explain unavailable explicit ring capture and receive-buffer/VLAN limitations.
24
+
5
25
  ## 2.0.0.rc1 - 2026-10-01
6
26
 
7
27
  ### Breaking changes
data/README.md CHANGED
@@ -14,7 +14,7 @@ for remaining GA evaluation gates.
14
14
  gem install redhound --pre
15
15
  ```
16
16
 
17
- Or add `gem 'redhound', '~> 2.0.0.rc1'` to your Gemfile.
17
+ Or add `gem 'redhound', '~> 2.0.0.rc2'` to your Gemfile.
18
18
 
19
19
  ## Usage
20
20
 
data/docs/API.md CHANGED
@@ -26,6 +26,16 @@ provides `next_packet`, `stats`, `interfaces`, `stop`, and `close`; a block alwa
26
26
  closes it. `Redhound.capture` yields packets and closes the live source even if
27
27
  the block fails. Capture options include `snaplen`, `promiscuous`, `buffer_size`
28
28
  (bytes), `direction` (`:in`, `:out`, `:inout`), `backend`, and `filter`.
29
+ On Linux Ruby 4.0, `backend: :auto` uses sockets because `IO::Buffer.map` cannot
30
+ map packet sockets; explicit `:ring` raises `UnsupportedPlatform`. Ruby 3.3/3.4
31
+ retain ring support.
32
+
33
+ `next_packet(timeout:)` returns nil at its deadline without discarding a partial
34
+ stdin/pipe record. `stop` interrupts a waiting read. `attach_filter(program)`
35
+ replaces a source's capture filter; filtering preserves the original wire length.
36
+ For pcapng input, retained interface metadata is limited to 4,096 interfaces and
37
+ 16 MiB across sections, with at most 4,096 options in a block. Excess input raises
38
+ `FileFormatError` rather than growing memory without a bound.
29
39
 
30
40
  `Redhound.dissect` creates an immutable-byte Packet and decodes its layers lazily.
31
41
  Packet metadata includes `timestamp_ns`, `original_length`, `linktype`,
@@ -35,6 +45,23 @@ selects a decoded field value. `layers_of`, `innermost`, and `field_values` expo
35
45
  repeated or tunneled layers. Layer has `values`, `fields`, and `diagnostics`.
36
46
  `to_h` follows [json-schema.json](json-schema.json); bytes use hexadecimal strings,
37
47
  addresses use text, booleans remain booleans, repeated fields become arrays.
48
+ `timestamp_ns` must be an Integer; calendar output preserves expanded years
49
+ outside 0000..9999. `number` is positive, `direction` is `:in`, `:out`, or nil,
50
+ and integer link types are restricted to 0..65535. Invalid UTF-8 field text,
51
+ interface names, and diagnostic text use hexadecimal strings without discarding
52
+ their original bytes.
53
+
54
+ Each Field exposes its `name`, `type`, `value`, `raw_value`, `offset`, and
55
+ `length`. For packet dissection, offsets are absolute captured byte positions;
56
+ lengths describe the encoded source span. Complete fixed fields remain available
57
+ when the rest of a header or its options are truncated. Derived values such as
58
+ `tcp.len`, `data.len`, and `igmp.version` have zero source length. DNS compressed
59
+ names refer to their encoded name/pointer span. Concatenated DHCP values and TLS
60
+ handshakes crossing records span the first through last source byte, including
61
+ intervening TLV/record headers; decoded values need not equal a direct byte slice.
62
+ Likewise, chunked HTTP body spans include chunk framing. Stream-reassembled
63
+ application fields use offsets in the logical PDU rather than the completing
64
+ packet's `data`; `tcp.reassembled_from` identifies the contributing frames.
38
65
 
39
66
  Writer accepts `format: :pcap|:pcapng`, `linktype`, `snaplen`, `precision`,
40
67
  `packet_buffered`, `max_bytes`, `interval`, `file_count`, and
@@ -58,7 +85,8 @@ class Example < Redhound::Dissector
58
85
  end
59
86
  ```
60
87
 
61
- Header fields support unsigned integers, MAC/IPv4/IPv6 addresses and bit fields.
88
+ Header fields support unsigned integers, signed `int8`, MAC/IPv4/IPv6 addresses
89
+ and bit fields.
62
90
  Use `dissect(ctx, layer)` for bounded variable-length parsing through
63
91
  `ctx.cursor`; append fields with `layer.add`. `next_dissector(ctx, layer)` returns
64
92
  a registered dissector class, or nil. Child cursors cannot escape their packet
data/docs/MIGRATION.md CHANGED
@@ -18,6 +18,6 @@ Interface indexes and Linux `any` are supported. Linux loopback duplicates are
18
18
  removed. Capture timestamps come from the kernel; captured and original packet
19
19
  lengths are recorded separately. pcapng supports multiple interfaces and metadata.
20
20
 
21
- Version 2.0.0.rc1 is a prerelease. The two-week RC evaluation and long capture
21
+ Version 2 is currently a prerelease. The two-week RC evaluation and long capture
22
22
  soak gates remain prerequisites for GA; a 1.x maintenance deadline will be
23
23
  announced when GA is published.
data/docs/PROTOCOLS.md CHANGED
@@ -17,7 +17,7 @@ ipv6: Internet Protocol v6 ipv6.version ipv6.tclass ipv6.flow ipv6.plen ipv6.nxt
17
17
  ipv6_ext: IPv6 extension
18
18
  udp: User Datagram Protocol udp.srcport udp.dstport udp.length udp.checksum
19
19
  tcp: Transmission Control Protocol tcp.srcport tcp.dstport tcp.seq tcp.ack tcp.hdr_len tcp.reserved tcp.flags tcp.window_size_value tcp.checksum tcp.urgent_pointer
20
- icmp: Internet Control Message Protocol icmp.type icmp.code icmp.checksum icmp.ident icmp.seq
20
+ icmp: Internet Control Message Protocol icmp.type icmp.code icmp.checksum
21
21
  icmpv6: ICMPv6 / Neighbor Discovery icmpv6.type icmpv6.code icmpv6.checksum
22
22
  igmp: Internet Group Management Protocol igmp.type igmp.max_resp igmp.checksum
23
23
  gre: Generic Routing Encapsulation gre.flags gre.proto
@@ -37,9 +37,18 @@ ClientHello/ServerHello metadata including SNI, ALPN and supported versions.
37
37
  DNS includes mDNS/LLMNR and TCP framing. IPv6 supports Hop-by-Hop, Routing,
38
38
  Fragment, Destination and AH headers; ESP stops dissection. GRE and VXLAN
39
39
  encapsulations decode inner packet layers.
40
+ ICMP identifiers/sequences are emitted for echo, timestamp, and address-mask
41
+ messages; fragmentation-needed errors expose `icmp.mtu` instead. IGMP reserved
42
+ report bytes are not labeled as maximum-response times. NTP timestamps such as
43
+ `ntp.xmt` preserve the unsigned 32.32 wire integer; their era-dependent calendar
44
+ interpretation is left to the caller.
40
45
 
41
46
  Dissectors use checked cursors and parent payload boundaries. Truncation,
42
47
  malformed lengths, checksum failures and reassembly gaps are available as
43
48
  structured diagnostics. Checksum verification is enabled by `-v` or
44
49
  `Engine.new(verify_checksums: true)`; outgoing/offloaded packets are marked
45
50
  unverified.
51
+ IPv6 No Next Header terminates the chain, and the 16-extension limit applies
52
+ independently to each encapsulated IPv6 header. Variable wire fields carry source
53
+ ranges; [API.md](API.md) describes compression, concatenation, and reassembly
54
+ span semantics.
data/docs/USAGE.md CHANGED
@@ -71,11 +71,15 @@ success, 1 on capture/file failures and 2 on invalid arguments.
71
71
 
72
72
  ## Platforms and limits
73
73
 
74
- Linux `auto` uses TPACKET_V3 on x86_64 and falls back to the socket backend when
74
+ Linux `auto` uses TPACKET_V3 on x86_64 and aarch64 and falls back to the socket backend when
75
75
  mapping is unavailable. Other Linux architectures default to sockets; `ring`
76
76
  can be selected explicitly. macOS uses BPF, with native timestamp precision
77
77
  reported by the device. Linux socket capture cannot recover NIC-stripped VLAN
78
78
  tags; choose the ring backend for that metadata.
79
+ Ruby 4.0's `IO::Buffer.map` rejects packet sockets, so `auto` uses socket capture
80
+ and explicit `ring` reports the mapping limitation. Use Ruby 3.3/3.4 for ring
81
+ capture. If using sockets, disable receive VLAN offload on the receiving
82
+ interface when VLAN tags are required (`ethtool -K IF rxvlan off`).
79
83
 
80
84
  Windows, Wi-Fi monitor mode, packet transmission, TLS decryption, complete
81
85
  HTTP/2/QUIC dissection, display-filter syntax and a TUI are outside v2's scope.
data/docs/VALIDATION.md CHANGED
@@ -1,8 +1,126 @@
1
1
  # Release validation
2
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
+
3
121
  ## 2.0.0.rc1
4
122
 
5
- The implementation covers the v2 roadmap: the packet/dissector API, network and
123
+ The release candidate implements the functional parts of the v2 roadmap: the packet/dissector API, network and
6
124
  application protocols, Ruby cBPF compiler/VM, Linux socket/ring and macOS BPF
7
125
  backends, capture files/rotation/privilege drop, bounded stateful analysis,
8
126
  statistics, follow output and documentation. This is a release candidate,
@@ -46,8 +164,6 @@ for throughput figures and measurement limits.
46
164
 
47
165
  - Run a 24-hour continuous capture stability check.
48
166
  - Run a 72-hour rotation soak and record RSS, descriptor counts and drop rates.
49
- - Run an hour of high-load aarch64 socket/ring comparison before changing its
50
- automatic backend default.
51
167
  - Confirm x86_64 single-core throughput and live drop targets on the specified
52
168
  workload; file benchmarks alone cannot establish loss-free live throughput.
53
169
  - Keep rc1 available for two weeks, classify reports, and fix critical/high
@@ -14,12 +14,12 @@
14
14
  "properties": {
15
15
  "number": { "type": "integer", "minimum": 1 },
16
16
  "time_epoch_ns": { "type": "integer" },
17
- "time": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{9}Z$" },
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
18
  "caplen": { "type": "integer", "minimum": 0 },
19
19
  "len": { "type": "integer", "minimum": 0 },
20
20
  "interface": { "type": ["string", "null"] },
21
21
  "direction": { "enum": ["in", "out", null] },
22
- "linktype": { "type": "integer", "minimum": 0 }
22
+ "linktype": { "type": "integer", "minimum": 0, "maximum": 65535 }
23
23
  }
24
24
  },
25
25
  "layers": {
@@ -27,7 +27,13 @@ module Redhound
27
27
  layers.reverse.find { |item| %i[ipv4 ipv6].include?(item.protocol) }
28
28
  end
29
29
  return nil unless network && network[:src] && network[:dst]
30
- proto = type || (transport ? transport.protocol : network.protocol)
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
31
37
  if %i[tcp udp].include?(proto)
32
38
  return nil unless transport && transport[:srcport] && transport[:dstport]
33
39
  ports = [transport[:srcport], transport[:dstport]]
@@ -7,7 +7,7 @@ module Redhound
7
7
  # @api private
8
8
  class Flow
9
9
  BASE_BYTES = 4096
10
- attr_reader :key, :id, :first_ns, :packets, :bytes, :streams, :applications, :acks, :keep_alives, :http_methods
10
+ attr_reader :key, :id, :first_ns, :packets, :bytes, :streams, :applications, :acks, :keep_alives, :http_methods, :probes, :probe_disabled
11
11
  attr_accessor :last_ns, :closed_ns, :syn_ns, :syn_direction, :synack_ns, :synack_seq, :initial_rtt, :accounted_bytes
12
12
  # @rbs (untyped key, Integer id, Integer timestamp_ns) -> void
13
13
  def initialize(key, id, timestamp_ns)
@@ -15,13 +15,15 @@ module Redhound
15
15
  @packets, @bytes, @streams, @applications, @acks = [0, 0], [0, 0], [nil, nil], [nil, nil], [nil, nil]
16
16
  @keep_alives = [false, false]
17
17
  @http_methods = [Array.new, Array.new] #: Array[untyped]
18
+ @probes = [Array.new, Array.new] #: Array[untyped]
19
+ @probe_disabled = [false, false]
18
20
  @closed_ns = @syn_ns = @syn_direction = @synack_ns = @synack_seq = @initial_rtt = nil # @rbs untyped
19
21
  @accounted_bytes = BASE_BYTES
20
22
  end
21
23
  # @rbs () -> bool
22
24
  def closed? = !@closed_ns.nil?
23
25
  # @rbs () -> Integer
24
- def bytesize = BASE_BYTES + @streams.compact.sum(&:bytesize) + @applications.compact.sum(&:bytesize) + @http_methods.sum { |methods| methods.size * 16 }
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 } }
25
27
  end
26
28
 
27
29
  # @api private
@@ -82,9 +84,14 @@ module Redhound
82
84
  stale
83
85
  end
84
86
  end
85
- # @rbs () -> Flow?
86
- def evict
87
- pair = @entries.shift
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
88
95
  return nil unless pair
89
96
  @evicted += 1
90
97
  discard(pair[1])
@@ -12,6 +12,7 @@ module Redhound
12
12
  raise ArgumentError, 'fragment limits must be positive' unless max_bytes.positive? && timeout_ns.positive?
13
13
  @max_bytes, @timeout_ns, @clock, @evicted, @expired = max_bytes, timeout_ns, 0, 0, 0
14
14
  @next_sweep = 0
15
+ @bytes = 0
15
16
  @entries, @discarded = {}, {} # @rbs untyped
16
17
  end
17
18
  # @rbs (Packet packet) -> untyped
@@ -21,6 +22,8 @@ module Redhound
21
22
  if layer.protocol == :ipv4 && layer[:src] && layer[:dst] && (layer[:mf] == 1 || layer[:frag_offset].to_i.positive?)
22
23
  return [layer, layer, [4, layer[:src], layer[:dst], layer[:proto], layer[:id]], layer[:frag_offset], layer[:mf] == 1]
23
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
24
27
  network = layers.take(index).reverse.find { |item| item.protocol == :ipv6 }
25
28
  return [network, layer, [6, network[:src], network[:dst], layer[:identification]], layer[:fragment_offset], layer[:more] == 1] if network && network[:src] && network[:dst]
26
29
  end
@@ -38,7 +41,7 @@ module Redhound
38
41
  length = network.payload_end - fragment.payload_offset
39
42
  if offset + length > 65_535 || length.negative? || (more && (length.zero? || length % 8 != 0))
40
43
  fragment.diagnose(:error, :malformed, 'invalid IP fragment size or offset')
41
- @entries.delete(key)
44
+ remove(key)
42
45
  return nil
43
46
  end
44
47
  if network.diagnostics.any? { |diagnostic| diagnostic.code == :truncated }
@@ -46,7 +49,7 @@ module Redhound
46
49
  return nil
47
50
  end
48
51
  data = packet.data.byteslice(fragment.payload_offset, length) #: String
49
- state = @entries.delete(key) || { parts: Array.new, frames: Array.new, header: nil, next_offset: 6, next: fragment[:next], end: nil } #: untyped
52
+ state = remove(key) || { parts: Array.new, frames: Array.new, header: nil, next_offset: 6, next: fragment[:next], end: nil } #: untyped
50
53
  if key[0] == 6 && state[:next] != fragment[:next]
51
54
  fragment.diagnose(:error, :malformed, 'IPv6 fragments have inconsistent next headers')
52
55
  return nil
@@ -58,11 +61,13 @@ module Redhound
58
61
  fragment.diagnose(:warning, :fragment_overlap, 'overlapping IP fragments')
59
62
  if key[0] == 6
60
63
  @discarded[key] = @clock
64
+ @bytes += 128
61
65
  while @discarded.size > [4096, @max_bytes / 128].min
62
66
  @discarded.shift
67
+ @bytes -= 128
63
68
  @evicted += 1
64
69
  end
65
- while bytesize > @max_bytes && @entries.shift
70
+ while bytesize > @max_bytes && remove_oldest
66
71
  @evicted += 1
67
72
  end
68
73
  return nil
@@ -103,12 +108,13 @@ module Redhound
103
108
  end
104
109
  end
105
110
  @entries[key] = state
111
+ @bytes += state_bytesize(state)
106
112
  if complete?(state)
107
- @entries.delete(key)
113
+ remove(key)
108
114
  return virtual_packet(packet, state, key[0])
109
115
  end
110
116
  while bytesize > @max_bytes
111
- removed = @entries.shift
117
+ removed = remove_oldest
112
118
  break unless removed
113
119
  @evicted += 1
114
120
  fragment.diagnose(:warning, :reassembly_gap, 'IP reassembly memory limit exceeded')
@@ -158,16 +164,43 @@ module Redhound
158
164
  @next_sweep = now_ns + 1_000_000_000
159
165
  @entries.delete_if do |_key, state|
160
166
  stale = now_ns - state[:time] >= @timeout_ns
161
- @expired += 1 if stale
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
162
176
  stale
163
177
  end
164
- @discarded.delete_if { |_key, time| now_ns - time >= @timeout_ns }
165
178
  end
166
179
  # @rbs () -> Integer
167
- def bytesize
168
- @discarded.size * 128 + @entries.values.sum do |state|
169
- 512 + (state[:header]&.bytesize || 0) + state[:frames].size * 32 + state[:parts].sum { |_pos, bytes| bytes.bytesize + 128 }
170
- end
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
171
204
  end
172
205
  # @rbs () -> Integer
173
206
  def size = @entries.size
@@ -38,6 +38,7 @@ module Redhound
38
38
  result = to_h
39
39
  io.puts("#{result[:kind]}#{result[:type] ? ",#{result[:type]}" : ''}")
40
40
  result[:rows].each { |item| io.puts(item.map { |name, value| "#{name}=#{value.is_a?(Array) ? value.join(':') : value}" }.join(' ')) }
41
+ io.puts("#{result[:omitted_intervals]} earlier IO intervals omitted") if result[:omitted_intervals] && result[:omitted_intervals].positive?
41
42
  io.puts("#{@evicted} statistics rows evicted") if @evicted.positive?
42
43
  end
43
44
  # @rbs () -> Hash[Symbol, untyped]
@@ -58,12 +59,23 @@ module Redhound
58
59
  @start_ns ||= packet.timestamp_ns
59
60
  start = @start_ns #: Integer
60
61
  interval = [(packet.timestamp_ns - start) / @interval_ns, 0].max
61
- item = row(interval) { { interval: interval, start_ns: start + interval * @interval_ns, end_ns: start + (interval + 1) * @interval_ns, packets: 0, bytes: 0 } }
62
+ item = row(interval) { interval_row(interval) }
62
63
  item[:packets] += 1
63
64
  item[:bytes] += packet.original_length
64
65
  end
65
66
  # @rbs () -> Hash[Symbol, untyped]
66
- def to_h = { kind: :io, interval_ns: @interval_ns, rows: @rows.values.sort_by { |item| item[:interval] } }
67
+ def to_h
68
+ return { kind: :io, interval_ns: @interval_ns, rows: [] } if @rows.empty?
69
+ last = @rows.keys.max #: Integer
70
+ first = [@rows.keys.min, last - @max_rows + 1].max #: Integer
71
+ rows = (first..last).map { |interval| @rows[interval] || interval_row(interval) }
72
+ { kind: :io, interval_ns: @interval_ns, omitted_intervals: first, rows: rows }
73
+ end
74
+ # @rbs (Integer interval) -> Hash[Symbol, Integer]
75
+ def interval_row(interval)
76
+ start = @start_ns || 0
77
+ { interval: interval, start_ns: start + interval * @interval_ns, end_ns: start + (interval + 1) * @interval_ns, packets: 0, bytes: 0 }
78
+ end
67
79
  end
68
80
 
69
81
  # @api private