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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +1 -1
- data/docs/API.md +29 -1
- data/docs/MIGRATION.md +1 -1
- data/docs/PROTOCOLS.md +10 -1
- data/docs/USAGE.md +5 -1
- data/docs/VALIDATION.md +119 -3
- data/docs/json-schema.json +2 -2
- data/lib/redhound/analysis/flow_key.rb +7 -1
- data/lib/redhound/analysis/flow_table.rb +12 -5
- data/lib/redhound/analysis/ip_reassembler.rb +44 -11
- data/lib/redhound/analysis/stats.rb +14 -2
- data/lib/redhound/analysis/tcp_reassembler.rb +68 -16
- data/lib/redhound/analysis/tcp_stream.rb +7 -2
- data/lib/redhound/analysis.rb +30 -2
- data/lib/redhound/capture/bsd/bpf_device.rb +3 -0
- data/lib/redhound/capture/bsd/ifreq.rb +41 -0
- data/lib/redhound/capture/file_source.rb +135 -4
- data/lib/redhound/capture/interface.rb +5 -1
- data/lib/redhound/capture/linux/packet_socket.rb +53 -9
- data/lib/redhound/capture/linux/tpacket_v3.rb +5 -0
- data/lib/redhound/capture.rb +3 -2
- data/lib/redhound/cli/command.rb +1 -1
- data/lib/redhound/dissector.rb +18 -4
- data/lib/redhound/engine.rb +3 -0
- data/lib/redhound/field.rb +1 -1
- data/lib/redhound/file/format.rb +8 -1
- data/lib/redhound/file/pcap_writer.rb +10 -3
- data/lib/redhound/file/pcapng_reader.rb +19 -7
- data/lib/redhound/file/pcapng_writer.rb +22 -14
- data/lib/redhound/filter/bpf/vm.rb +3 -2
- data/lib/redhound/output/follow.rb +6 -1
- data/lib/redhound/output/summary.rb +29 -4
- data/lib/redhound/output/timestamp.rb +2 -2
- data/lib/redhound/output/tree.rb +2 -1
- data/lib/redhound/packet.rb +18 -4
- data/lib/redhound/protocols/data.rb +3 -1
- data/lib/redhound/protocols/dhcp.rb +29 -13
- data/lib/redhound/protocols/http.rb +5 -3
- data/lib/redhound/protocols/icmp.rb +55 -41
- data/lib/redhound/protocols/ipv6.rb +5 -2
- data/lib/redhound/protocols/ntp.rb +2 -4
- data/lib/redhound/protocols/tcp.rb +17 -9
- data/lib/redhound/protocols/tunnel.rb +2 -2
- data/lib/redhound/stream_dissector.rb +2 -2
- data/lib/redhound/version.rb +1 -1
- data/sig/generated/redhound/analysis/flow_table.rbs +6 -2
- data/sig/generated/redhound/analysis/ip_reassembler.rbs +12 -0
- data/sig/generated/redhound/analysis/stats.rbs +3 -0
- data/sig/generated/redhound/analysis/tcp_reassembler.rbs +12 -0
- data/sig/generated/redhound/capture/bsd/ifreq.rbs +15 -0
- data/sig/generated/redhound/capture/file_source.rbs +33 -0
- data/sig/generated/redhound/capture/linux/packet_socket.rbs +7 -0
- data/sig/generated/redhound/dissector.rbs +4 -0
- data/sig/generated/redhound/file/format.rbs +6 -0
- data/sig/generated/redhound/file/pcapng_reader.rbs +2 -2
- data/sig/generated/redhound/filter/bpf/vm.rbs +2 -0
- data/sig/generated/redhound/packet.rbs +5 -0
- data/sig/generated/redhound/protocols/data.rbs +2 -0
- data/sig/generated/redhound/protocols/dhcp.rbs +7 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33f3a971291b9d5c6be138098ab5d6072717b8ffc79f418830f3538d8b9c4500
|
|
4
|
+
data.tar.gz: 191cf625ab53877b304432e4cd0b9caa6725afd1e4929bebaba31ef2382c0108
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/docs/json-schema.json
CHANGED
|
@@ -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": "
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 &&
|
|
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
|
-
|
|
113
|
+
remove(key)
|
|
108
114
|
return virtual_packet(packet, state, key[0])
|
|
109
115
|
end
|
|
110
116
|
while bytesize > @max_bytes
|
|
111
|
-
removed =
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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) {
|
|
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
|
|
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
|