redhound 1.0.0 → 2.0.0.rc1

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 (230) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +97 -31
  4. data/docs/API.md +76 -0
  5. data/docs/FILTERS.md +37 -0
  6. data/docs/MIGRATION.md +23 -0
  7. data/docs/PROTOCOLS.md +45 -0
  8. data/docs/USAGE.md +82 -0
  9. data/docs/VALIDATION.md +59 -0
  10. data/docs/json-schema.json +64 -0
  11. data/exe/redhound +2 -1
  12. data/lib/redhound/analysis/flow_key.rb +53 -0
  13. data/lib/redhound/analysis/flow_table.rb +117 -0
  14. data/lib/redhound/analysis/ip_reassembler.rb +176 -0
  15. data/lib/redhound/analysis/stats.rb +138 -0
  16. data/lib/redhound/analysis/tcp_analysis.rb +43 -0
  17. data/lib/redhound/analysis/tcp_reassembler.rb +124 -0
  18. data/lib/redhound/analysis/tcp_stream.rb +151 -0
  19. data/lib/redhound/analysis.rb +129 -0
  20. data/lib/redhound/capture/bsd/bpf_device.rb +152 -0
  21. data/lib/redhound/capture/bsd/constants.rb +29 -0
  22. data/lib/redhound/capture/file_source.rb +83 -0
  23. data/lib/redhound/capture/interface.rb +66 -0
  24. data/lib/redhound/capture/linktype.rb +30 -0
  25. data/lib/redhound/capture/linux/constants.rb +37 -0
  26. data/lib/redhound/capture/linux/cooked.rb +18 -0
  27. data/lib/redhound/capture/linux/ifreq.rb +42 -0
  28. data/lib/redhound/capture/linux/packet_socket.rb +180 -0
  29. data/lib/redhound/capture/linux/sockaddr_ll.rb +33 -0
  30. data/lib/redhound/capture/linux/tpacket_v3.rb +132 -0
  31. data/lib/redhound/capture/source.rb +79 -0
  32. data/lib/redhound/capture/stats.rb +22 -0
  33. data/lib/redhound/capture.rb +69 -0
  34. data/lib/redhound/cli/command.rb +42 -0
  35. data/lib/redhound/cli/options.rb +133 -0
  36. data/lib/redhound/cli/privileges.rb +29 -0
  37. data/lib/redhound/cli/runner.rb +129 -0
  38. data/lib/redhound/context.rb +20 -0
  39. data/lib/redhound/cursor.rb +69 -0
  40. data/lib/redhound/diagnostic.rb +7 -0
  41. data/lib/redhound/dissector.rb +138 -0
  42. data/lib/redhound/engine.rb +66 -0
  43. data/lib/redhound/errors.rb +38 -0
  44. data/lib/redhound/field.rb +62 -0
  45. data/lib/redhound/file/format.rb +56 -0
  46. data/lib/redhound/file/pcap_reader.rb +43 -0
  47. data/lib/redhound/file/pcap_writer.rb +72 -0
  48. data/lib/redhound/file/pcapng_reader.rb +209 -0
  49. data/lib/redhound/file/pcapng_writer.rb +143 -0
  50. data/lib/redhound/file/rotating_writer.rb +98 -0
  51. data/lib/redhound/filter/analyzer.rb +197 -0
  52. data/lib/redhound/filter/bpf/assembler.rb +121 -0
  53. data/lib/redhound/filter/bpf/disassembler.rb +58 -0
  54. data/lib/redhound/filter/bpf/validator.rb +77 -0
  55. data/lib/redhound/filter/bpf/vm.rb +108 -0
  56. data/lib/redhound/filter/codegen.rb +413 -0
  57. data/lib/redhound/filter/lexer.rb +46 -0
  58. data/lib/redhound/filter/parser.rb +206 -0
  59. data/lib/redhound/filter/program.rb +55 -0
  60. data/lib/redhound/filter.rb +30 -0
  61. data/lib/redhound/layer.rb +75 -0
  62. data/lib/redhound/output/fields.rb +20 -0
  63. data/lib/redhound/output/follow.rb +59 -0
  64. data/lib/redhound/output/hexdump.rb +29 -0
  65. data/lib/redhound/output/json.rb +32 -0
  66. data/lib/redhound/output/summary.rb +59 -0
  67. data/lib/redhound/output/timestamp.rb +34 -0
  68. data/lib/redhound/output/tree.rb +24 -0
  69. data/lib/redhound/packet.rb +63 -0
  70. data/lib/redhound/protocols/arp.rb +39 -0
  71. data/lib/redhound/protocols/checksum.rb +38 -0
  72. data/lib/redhound/protocols/data.rb +21 -0
  73. data/lib/redhound/protocols/dhcp.rb +124 -0
  74. data/lib/redhound/protocols/dns.rb +268 -0
  75. data/lib/redhound/protocols/ethernet.rb +24 -0
  76. data/lib/redhound/protocols/http.rb +183 -0
  77. data/lib/redhound/protocols/icmp.rb +191 -0
  78. data/lib/redhound/protocols/ipv4.rb +56 -0
  79. data/lib/redhound/protocols/ipv6.rb +65 -0
  80. data/lib/redhound/protocols/link.rb +70 -0
  81. data/lib/redhound/protocols/llc.rb +37 -0
  82. data/lib/redhound/protocols/ntp.rb +47 -0
  83. data/lib/redhound/protocols/tcp.rb +80 -0
  84. data/lib/redhound/protocols/tls.rb +218 -0
  85. data/lib/redhound/protocols/tunnel.rb +48 -0
  86. data/lib/redhound/protocols/udp.rb +32 -0
  87. data/lib/redhound/protocols/vlan.rb +23 -0
  88. data/lib/redhound/reader.rb +8 -0
  89. data/lib/redhound/registry.rb +64 -0
  90. data/lib/redhound/stream_dissector.rb +168 -0
  91. data/lib/redhound/util/seq.rb +21 -0
  92. data/lib/redhound/version.rb +2 -1
  93. data/lib/redhound/writer.rb +25 -43
  94. data/lib/redhound.rb +81 -9
  95. data/sig/generated/redhound/analysis/flow_key.rbs +15 -0
  96. data/sig/generated/redhound/analysis/flow_table.rbs +98 -0
  97. data/sig/generated/redhound/analysis/ip_reassembler.rbs +40 -0
  98. data/sig/generated/redhound/analysis/stats.rbs +77 -0
  99. data/sig/generated/redhound/analysis/tcp_analysis.rbs +15 -0
  100. data/sig/generated/redhound/analysis/tcp_reassembler.rbs +27 -0
  101. data/sig/generated/redhound/analysis/tcp_stream.rbs +60 -0
  102. data/sig/generated/redhound/analysis.rbs +38 -0
  103. data/sig/generated/redhound/capture/bsd/bpf_device.rbs +39 -0
  104. data/sig/generated/redhound/capture/bsd/constants.rbs +40 -0
  105. data/sig/generated/redhound/capture/file_source.rbs +34 -0
  106. data/sig/generated/redhound/capture/interface.rbs +53 -0
  107. data/sig/generated/redhound/capture/linktype.rbs +20 -0
  108. data/sig/generated/redhound/capture/linux/constants.rbs +58 -0
  109. data/sig/generated/redhound/capture/linux/cooked.rbs +15 -0
  110. data/sig/generated/redhound/capture/linux/ifreq.rbs +18 -0
  111. data/sig/generated/redhound/capture/linux/packet_socket.rbs +57 -0
  112. data/sig/generated/redhound/capture/linux/sockaddr_ll.rbs +31 -0
  113. data/sig/generated/redhound/capture/linux/tpacket_v3.rbs +35 -0
  114. data/sig/generated/redhound/capture/source.rbs +44 -0
  115. data/sig/generated/redhound/capture/stats.rbs +25 -0
  116. data/sig/generated/redhound/capture.rbs +14 -0
  117. data/sig/generated/redhound/cli/command.rbs +12 -0
  118. data/sig/generated/redhound/cli/options.rbs +25 -0
  119. data/sig/generated/redhound/cli/privileges.rbs +12 -0
  120. data/sig/generated/redhound/cli/runner.rbs +27 -0
  121. data/sig/generated/redhound/context.rbs +27 -0
  122. data/sig/generated/redhound/cursor.rbs +59 -0
  123. data/sig/generated/redhound/diagnostic.rbs +21 -0
  124. data/sig/generated/redhound/dissector.rbs +113 -0
  125. data/sig/generated/redhound/engine.rbs +23 -0
  126. data/sig/generated/redhound/errors.rbs +56 -0
  127. data/sig/generated/redhound/field.rbs +74 -0
  128. data/sig/generated/redhound/file/format.rbs +33 -0
  129. data/sig/generated/redhound/file/pcap_reader.rbs +19 -0
  130. data/sig/generated/redhound/file/pcap_writer.rbs +38 -0
  131. data/sig/generated/redhound/file/pcapng_reader.rbs +54 -0
  132. data/sig/generated/redhound/file/pcapng_writer.rbs +50 -0
  133. data/sig/generated/redhound/file/rotating_writer.rbs +42 -0
  134. data/sig/generated/redhound/filter/analyzer.rbs +42 -0
  135. data/sig/generated/redhound/filter/bpf/assembler.rbs +39 -0
  136. data/sig/generated/redhound/filter/bpf/disassembler.rbs +18 -0
  137. data/sig/generated/redhound/filter/bpf/validator.rbs +30 -0
  138. data/sig/generated/redhound/filter/bpf/vm.rbs +26 -0
  139. data/sig/generated/redhound/filter/codegen.rbs +101 -0
  140. data/sig/generated/redhound/filter/lexer.rbs +15 -0
  141. data/sig/generated/redhound/filter/parser.rbs +65 -0
  142. data/sig/generated/redhound/filter/program.rbs +48 -0
  143. data/sig/generated/redhound/filter.rbs +10 -0
  144. data/sig/generated/redhound/layer.rbs +81 -0
  145. data/sig/generated/redhound/output/fields.rbs +15 -0
  146. data/sig/generated/redhound/output/follow.rbs +21 -0
  147. data/sig/generated/redhound/output/hexdump.rbs +15 -0
  148. data/sig/generated/redhound/output/json.rbs +18 -0
  149. data/sig/generated/redhound/output/summary.rbs +21 -0
  150. data/sig/generated/redhound/output/timestamp.rbs +21 -0
  151. data/sig/generated/redhound/output/tree.rbs +15 -0
  152. data/sig/generated/redhound/packet.rbs +77 -0
  153. data/sig/generated/redhound/protocols/arp.rbs +15 -0
  154. data/sig/generated/redhound/protocols/checksum.rbs +15 -0
  155. data/sig/generated/redhound/protocols/data.rbs +15 -0
  156. data/sig/generated/redhound/protocols/dhcp.rbs +28 -0
  157. data/sig/generated/redhound/protocols/dns.rbs +42 -0
  158. data/sig/generated/redhound/protocols/ethernet.rbs +15 -0
  159. data/sig/generated/redhound/protocols/http.rbs +36 -0
  160. data/sig/generated/redhound/protocols/icmp.rbs +41 -0
  161. data/sig/generated/redhound/protocols/ipv4.rbs +15 -0
  162. data/sig/generated/redhound/protocols/ipv6.rbs +24 -0
  163. data/sig/generated/redhound/protocols/link.rbs +33 -0
  164. data/sig/generated/redhound/protocols/llc.rbs +15 -0
  165. data/sig/generated/redhound/protocols/ntp.rbs +17 -0
  166. data/sig/generated/redhound/protocols/tcp.rbs +25 -0
  167. data/sig/generated/redhound/protocols/tls.rbs +51 -0
  168. data/sig/generated/redhound/protocols/tunnel.rbs +24 -0
  169. data/sig/generated/redhound/protocols/udp.rbs +18 -0
  170. data/sig/generated/redhound/protocols/vlan.rbs +12 -0
  171. data/sig/generated/redhound/reader.rbs +7 -0
  172. data/sig/generated/redhound/registry.rbs +45 -0
  173. data/sig/generated/redhound/stream_dissector.rbs +44 -0
  174. data/sig/generated/redhound/util/seq.rbs +20 -0
  175. data/sig/generated/redhound/version.rbs +1 -0
  176. data/sig/generated/redhound/writer.rbs +6 -19
  177. data/sig/generated/redhound.rbs +18 -0
  178. data/sig/shims/io.rbs +7 -0
  179. data/sig/shims/process.rbs +3 -0
  180. metadata +175 -55
  181. data/CODE_OF_CONDUCT.md +0 -132
  182. data/Dockerfile +0 -7
  183. data/Rakefile +0 -15
  184. data/Steepfile +0 -4
  185. data/docker-compose.yml +0 -9
  186. data/lib/redhound/analyzer.rb +0 -34
  187. data/lib/redhound/builder/packet_mreq.rb +0 -56
  188. data/lib/redhound/builder/socket.rb +0 -35
  189. data/lib/redhound/builder.rb +0 -4
  190. data/lib/redhound/command.rb +0 -67
  191. data/lib/redhound/l2/ether.rb +0 -68
  192. data/lib/redhound/l2/protocol.rb +0 -33
  193. data/lib/redhound/l2.rb +0 -4
  194. data/lib/redhound/l3/arp.rb +0 -114
  195. data/lib/redhound/l3/base.rb +0 -49
  196. data/lib/redhound/l3/ipv4.rb +0 -95
  197. data/lib/redhound/l3/ipv6.rb +0 -69
  198. data/lib/redhound/l3/protocol.rb +0 -173
  199. data/lib/redhound/l3/resolver.rb +0 -30
  200. data/lib/redhound/l3.rb +0 -8
  201. data/lib/redhound/l4/base.rb +0 -31
  202. data/lib/redhound/l4/icmp.rb +0 -71
  203. data/lib/redhound/l4/resolver.rb +0 -28
  204. data/lib/redhound/l4/udp.rb +0 -63
  205. data/lib/redhound/l4.rb +0 -6
  206. data/lib/redhound/receiver.rb +0 -45
  207. data/lib/redhound/resolver.rb +0 -22
  208. data/lib/redhound/source/socket.rb +0 -18
  209. data/lib/redhound/source.rb +0 -3
  210. data/rbs_collection.lock.yaml +0 -20
  211. data/rbs_collection.yaml +0 -17
  212. data/sig/generated/redhound/analyzer.rbs +0 -14
  213. data/sig/generated/redhound/builder/packet_mreq.rbs +0 -39
  214. data/sig/generated/redhound/builder/socket.rbs +0 -24
  215. data/sig/generated/redhound/command.rbs +0 -19
  216. data/sig/generated/redhound/l2/ether.rbs +0 -41
  217. data/sig/generated/redhound/l2/protocol.rbs +0 -15
  218. data/sig/generated/redhound/l3/arp.rbs +0 -57
  219. data/sig/generated/redhound/l3/base.rbs +0 -28
  220. data/sig/generated/redhound/l3/ipv4.rbs +0 -53
  221. data/sig/generated/redhound/l3/ipv6.rbs +0 -38
  222. data/sig/generated/redhound/l3/protocol.rbs +0 -16
  223. data/sig/generated/redhound/l3/resolver.rbs +0 -16
  224. data/sig/generated/redhound/l4/base.rbs +0 -19
  225. data/sig/generated/redhound/l4/icmp.rbs +0 -33
  226. data/sig/generated/redhound/l4/resolver.rbs +0 -16
  227. data/sig/generated/redhound/l4/udp.rbs +0 -36
  228. data/sig/generated/redhound/receiver.rbs +0 -19
  229. data/sig/generated/redhound/resolver.rbs +0 -11
  230. data/sig/generated/redhound/source/socket.rbs +0 -13
@@ -0,0 +1,62 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'ipaddr'
5
+
6
+ module Redhound
7
+ # Immutable description of a field location, type and display metadata.
8
+ FieldDefinition = Data.define(:key, :name, :type, :offset, :length, :index, :shift, :mask, :scale, :format, :enum)
9
+
10
+ # A decoded field with its definition, raw value and absolute location.
11
+ class Field
12
+ # Definition, raw decoded value and capture location.
13
+ attr_reader :definition, :raw_value, :offset, :length
14
+
15
+ # @rbs (untyped definition, untyped value, Integer base) -> void
16
+ # Bind a field definition to its value and layer base offset.
17
+ def initialize(definition, value, base)
18
+ @definition, @raw_value = definition, value
19
+ @offset, @length = base + definition.offset, definition.length
20
+ end
21
+
22
+ # @rbs () -> String
23
+ # Stable dotted field name.
24
+ def name = @definition.name
25
+ # @rbs () -> Symbol
26
+ # Declared field representation.
27
+ def type = @definition.type
28
+
29
+ # @rbs () -> untyped
30
+ # Decoded value; addresses are returned in human-readable text.
31
+ def value
32
+ case type
33
+ when :ipv4 then [@raw_value].pack('N').unpack('C4').join('.')
34
+ when :ipv6 then IPAddr.new_ntoh(@raw_value).to_s
35
+ when :mac then @raw_value.unpack('H2' * 6).join(':')
36
+ else @raw_value
37
+ end
38
+ end
39
+
40
+ # @rbs () -> untyped
41
+ # JSON-safe value; opaque bytes and invalid text are encoded in hexadecimal.
42
+ def json_value
43
+ return @raw_value.unpack1('H*') if type == :bytes
44
+ val = value
45
+ return val unless val.is_a?(String)
46
+ utf8 = val.dup.force_encoding(Encoding::UTF_8)
47
+ utf8.valid_encoding? ? utf8 : val.unpack1('H*')
48
+ end
49
+
50
+ # @rbs () -> String
51
+ # Terminal-safe field text with escaped control and non-ASCII bytes.
52
+ def display
53
+ val = value
54
+ return val if type == :ipv4 || type == :ipv6 || type == :mac
55
+ return "#{@definition.enum[val]} (#{val})" if @definition.enum&.key?(val)
56
+ return format('0x%x', val) if @definition.format == :hex
57
+ return @raw_value.unpack1('H*') if type == :bytes
58
+ return val.b.gsub(/[^\x20-\x7e]/n) { |c| format('\\x%02x', c.getbyte(0)) } if val.is_a?(String)
59
+ val.to_s
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ module Format
9
+ MAX_RECORD = 16 * 1024 * 1024
10
+ PCAP_MAGICS = {
11
+ "\xd4\xc3\xb2\xa1".b => [:little, 1_000], "\xa1\xb2\xc3\xd4".b => [:big, 1_000],
12
+ "\x4d\x3c\xb2\xa1".b => [:little, 1], "\xa1\xb2\x3c\x4d".b => [:big, 1]
13
+ }.freeze
14
+ PCAPNG_MAGIC = "\x0a\x0d\x0d\x0a".b.freeze
15
+
16
+ # @rbs (untyped io, Integer length, ?eof: false) -> String
17
+ # @rbs (untyped io, Integer length, eof: true) -> String?
18
+ def self.read_exact(io, length, eof: false)
19
+ raise FileFormatError, "invalid record length #{length}" unless length.between?(0, MAX_RECORD)
20
+ return ''.b if length.zero?
21
+
22
+ result = ''.b
23
+ while result.bytesize < length
24
+ part = io.read(length - result.bytesize)
25
+ if !part || part.empty?
26
+ return nil if eof && result.empty?
27
+
28
+ raise FileFormatError, "truncated capture file (wanted #{length}, read #{result.bytesize})"
29
+ end
30
+ result << part
31
+ end
32
+ result
33
+ end
34
+
35
+ # @rbs (Integer length) -> Integer
36
+ def self.padded(length) = (length + 3) & ~3
37
+
38
+ # @rbs (String data) -> String
39
+ def self.pad(data) = data + "\0" * (padded(data.bytesize) - data.bytesize)
40
+
41
+ # NULL carries a host-order address family; libpcap also normalizes it on read.
42
+ # @rbs (String data, Integer linktype, String integer_format) -> String
43
+ def self.normalize_null(data, linktype, integer_format)
44
+ data[0, 4] = [data.unpack1(integer_format)].pack('L') if linktype.zero? && data.bytesize >= 4
45
+ data
46
+ end
47
+
48
+ # @rbs (Integer caplen, Integer original_length, Integer snaplen) -> void
49
+ def self.check_lengths(caplen, original_length, snaplen)
50
+ unless caplen.between?(0, MAX_RECORD) && (snaplen.zero? || caplen <= snaplen) && original_length >= caplen
51
+ raise FileFormatError, "invalid packet lengths (captured #{caplen}, original #{original_length}, snaplen #{snaplen})"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,43 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapReader
9
+ attr_reader :interfaces, :linktype
10
+
11
+ # @rbs (untyped io, String magic) -> void
12
+ def initialize(io, magic)
13
+ @io = io
14
+ endian, @multiplier = Format::PCAP_MAGICS.fetch(magic) #: [Symbol, Integer]
15
+ @integer_format = endian == :little ? 'V' : 'N'
16
+ header = Format.read_exact(io, 20)
17
+ major, minor, _zone, _accuracy, @snaplen, network = header.unpack(endian == :little ? 'vvV4' : 'nnN4') #: [Integer, Integer, Integer, Integer, Integer, Integer]
18
+ raise FileFormatError, "unsupported pcap version #{major}.#{minor}" unless major == 2 && minor == 4
19
+ raise FileFormatError, 'invalid pcap snaplen' unless @snaplen.between?(1, Format::MAX_RECORD)
20
+
21
+ @linktype = network & 0xffff
22
+ @interfaces = [Capture::Interface.new(name: 'pcap', linktype: @linktype, snaplen: @snaplen)]
23
+ @number = 0
24
+ end
25
+
26
+ # @rbs () -> Packet?
27
+ def next_packet
28
+ record = Format.read_exact(@io, 16, eof: true)
29
+ return nil unless record
30
+
31
+ seconds, fraction, caplen, original_length = record.unpack("#{@integer_format}4") #: [Integer, Integer, Integer, Integer]
32
+ Format.check_lengths(caplen, original_length, @snaplen)
33
+ raise FileFormatError, 'invalid pcap timestamp fraction' unless fraction < 1_000_000_000 / @multiplier
34
+
35
+ data = Format.normalize_null(Format.read_exact(@io, caplen), @linktype, @integer_format)
36
+ @number += 1
37
+ timestamp_ns = seconds * 1_000_000_000 + fraction * @multiplier #: Integer
38
+ Packet.new(data, timestamp_ns:,
39
+ original_length:, linktype: @linktype, interface: @interfaces.first, number: @number)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,72 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapWriter
9
+ attr_reader :bytes_written, :path
10
+
11
+ # @rbs (untyped output, ?linktype: Integer | Symbol | String, ?snaplen: Integer, ?precision: Symbol, ?packet_buffered: bool, **untyped) -> void
12
+ def initialize(output, linktype: :ethernet, snaplen: 262_144, precision: :nano, packet_buffered: false, **_options)
13
+ raise ArgumentError, 'snaplen must be between 1 and 16 MiB' unless snaplen.between?(1, Format::MAX_RECORD)
14
+ raise ArgumentError, 'precision must be micro or nano' unless %i[micro nano].include?(precision)
15
+
16
+ @linktype = Capture::Linktype.resolve(linktype)
17
+ @snaplen, @precision, @packet_buffered = snaplen, precision, packet_buffered
18
+ @owned = output.is_a?(String) && output != '-'
19
+ @path = output.is_a?(String) ? output : nil
20
+ @io = output.is_a?(String) ? (output == '-' ? $stdout : ::File.open(output, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600)) : output
21
+ @io.chmod(0o600) if @owned
22
+ @io.binmode if @io.respond_to?(:binmode)
23
+ @bytes_written = 0
24
+ @closed = false
25
+ magic = precision == :nano ? 0xa1b23c4d : 0xa1b2c3d4
26
+ write_bytes([magic, 2, 4, 0, 0, snaplen, @linktype].pack('VvvV4'))
27
+ end
28
+
29
+ # @rbs (Packet packet) -> self
30
+ def write(packet)
31
+ raise IOError, 'writer is closed' if @closed
32
+ raise FileFormatError, 'pcap cannot store multiple link types; use pcapng' unless packet.linktype == @linktype
33
+
34
+ seconds, nanos = packet.timestamp_ns.divmod(1_000_000_000)
35
+ raise FileFormatError, 'pcap timestamp exceeds unsigned 32-bit seconds' unless seconds.between?(0, 0xffff_ffff)
36
+
37
+ data = packet.data.byteslice(0, @snaplen)
38
+ fraction = @precision == :nano ? nanos : nanos / 1_000
39
+ write_bytes([seconds, fraction, data.bytesize, packet.original_length].pack('V4') + data)
40
+ @io.flush if @packet_buffered
41
+ self
42
+ end
43
+
44
+ alias << write
45
+
46
+ # @rbs (Packet packet) -> Integer
47
+ def record_size(packet) = 16 + [packet.caplen, @snaplen].min
48
+
49
+ # @rbs (Capture::Stats stats, ?interface: Capture::Interface?) -> void
50
+ def write_stats(stats, interface: nil); end
51
+
52
+ # @rbs () -> void
53
+ def flush = @io.flush
54
+
55
+ # @rbs () -> void
56
+ def close
57
+ return if @closed
58
+
59
+ @io.flush
60
+ @io.close if @owned
61
+ @closed = true
62
+ end
63
+
64
+ private
65
+
66
+ # @rbs (String bytes) -> void
67
+ def write_bytes(bytes)
68
+ @bytes_written += @io.write(bytes)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,209 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapngReader
9
+ attr_reader :interfaces, :stats
10
+
11
+ # @rbs (untyped io, String magic) -> void
12
+ def initialize(io, magic)
13
+ @io = io
14
+ @interfaces = [] #: Array[Capture::Interface]
15
+ @section_interfaces = [] #: Array[Capture::Interface]
16
+ @statistics = {} #: Hash[Integer, Hash[Symbol, Integer]]
17
+ @packet_counts = {} #: Hash[Integer, Integer]
18
+ @stats = Capture::Stats.new
19
+ @number = 0
20
+ @magic = magic
21
+ @integer_format = 'V'
22
+ read_block
23
+ while (block = read_block)
24
+ type, body = block
25
+ if type == 1
26
+ read_interface(body)
27
+ break
28
+ end
29
+ raise FileFormatError, 'pcapng packet precedes interface description' if [3, 5, 6].include?(type)
30
+ end
31
+ end
32
+
33
+ # @rbs () -> Integer
34
+ def linktype = @interfaces.first&.linktype || 1
35
+
36
+ # @rbs () -> bool
37
+ def statistics? = !@statistics.empty?
38
+
39
+ # @rbs () -> Packet?
40
+ def next_packet
41
+ while (block = read_block)
42
+ type, body = block
43
+ case type
44
+ when 1 then read_interface(body)
45
+ when 6 then return read_enhanced(body)
46
+ when 3 then return read_simple(body)
47
+ when 5 then read_statistics(body)
48
+ end
49
+ end
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ # @rbs () -> [Integer, String]?
56
+ def read_block
57
+ header = @magic ? @magic + Format.read_exact(@io, 4) : Format.read_exact(@io, 8, eof: true)
58
+ @magic = nil
59
+ return nil unless header
60
+
61
+ section = header.byteslice(0, 4) == Format::PCAPNG_MAGIC
62
+ prefix = section ? Format.read_exact(@io, 4) : ''.b
63
+ if section
64
+ @integer_format = case prefix
65
+ when "\x4d\x3c\x2b\x1a".b then 'V'
66
+ when "\x1a\x2b\x3c\x4d".b then 'N'
67
+ else raise FileFormatError, 'invalid pcapng byte order magic'
68
+ end
69
+ end
70
+ type, length = header.unpack("#{@integer_format}2") #: [Integer, Integer]
71
+ minimum = section ? 28 : 12
72
+ unless length.between?(minimum, Format::MAX_RECORD) && (length & 3).zero?
73
+ raise FileFormatError, "invalid pcapng block length #{length}"
74
+ end
75
+ contents = prefix + Format.read_exact(@io, length - 8 - prefix.bytesize)
76
+ raise FileFormatError, 'pcapng block lengths do not match' unless contents.unpack1(@integer_format, offset: contents.bytesize - 4) == length
77
+
78
+ body = contents.byteslice(0, contents.bytesize - 4) #: String
79
+ if section
80
+ version = body.unpack(@integer_format == 'V' ? 'vv' : 'nn', offset: 4)
81
+ raise FileFormatError, 'unsupported pcapng section version' unless version == [1, 0]
82
+
83
+ @section_interfaces = [] #: Array[Capture::Interface]
84
+ end
85
+ [type, body]
86
+ end
87
+
88
+ # @rbs (String body) -> void
89
+ def read_interface(body)
90
+ require_size(body, 8)
91
+ linktype, _reserved, snaplen = body.unpack(@integer_format == 'V' ? 'vvV' : 'nnN') #: [Integer, Integer, Integer]
92
+ raise FileFormatError, 'invalid pcapng snaplen' if snaplen > Format::MAX_RECORD
93
+
94
+ opts = options(body, 8)
95
+ resolution = opts.fetch(9, ["\x06".b]).fetch(0)
96
+ raise FileFormatError, 'invalid pcapng timestamp resolution' unless resolution.bytesize == 1
97
+
98
+ exponent = resolution.getbyte(0) #: Integer
99
+ units = exponent < 128 ? 10**exponent : 2**(exponent & 127)
100
+ offset = opts.fetch(14, ["\0" * 8]).fetch(0)
101
+ raise FileFormatError, 'invalid pcapng timestamp offset' unless offset.bytesize == 8
102
+
103
+ index = @section_interfaces.size
104
+ filter_option = opts[11]&.first
105
+ interface = Capture::Interface.new(name: opts.fetch(2, ["interface#{index}"]).fetch(0), index:, linktype:, snaplen:,
106
+ description: opts[3]&.first, filter: filter_option && filter_option.getbyte(0) == 0 ? filter_option.byteslice(1..) : nil,
107
+ meta: { timestamp_units: units, timestamp_offset: offset.unpack1(@integer_format == 'V' ? 'q<' : 'q>') })
108
+ @section_interfaces << interface
109
+ @interfaces << interface
110
+ end
111
+
112
+ # @rbs (String body) -> Packet
113
+ def read_enhanced(body)
114
+ require_size(body, 20)
115
+ index, high, low, caplen, original_length = body.unpack("#{@integer_format}5") #: [Integer, Integer, Integer, Integer, Integer]
116
+ interface = interface_at(index)
117
+ Format.check_lengths(caplen, original_length, interface.snaplen)
118
+ require_size(body, 20 + Format.padded(caplen))
119
+ opts = options(body, 20 + Format.padded(caplen))
120
+ flags_option = opts[2]&.first
121
+ raise FileFormatError, 'invalid pcapng packet flags' if flags_option && flags_option.bytesize != 4
122
+
123
+ flags = flags_option ? flags_option.unpack1(@integer_format) : 0 #: Integer
124
+ direction = { 1 => :in, 2 => :out }[flags & 3]
125
+ meta = { epb_flags: flags } #: Hash[Symbol, untyped]
126
+ meta[:comment] = opts[1].join("\n") if opts[1]
127
+ pkttype = direction == :out ? 4 : { 1 => 0, 2 => 2, 3 => 1, 4 => 3 }[(flags >> 2) & 7]
128
+ meta[:pkttype] = pkttype if pkttype
129
+ timestamp_ns = ((high << 32) | low) * 1_000_000_000 / interface.meta.fetch(:timestamp_units) +
130
+ interface.meta.fetch(:timestamp_offset) * 1_000_000_000
131
+ data = body.byteslice(20, caplen) #: String
132
+ make_packet(data, interface, original_length, timestamp_ns, direction, meta)
133
+ end
134
+
135
+ # @rbs (String body) -> Packet
136
+ def read_simple(body)
137
+ require_size(body, 4)
138
+ interface = interface_at(0)
139
+ original_length = body.unpack1(@integer_format) #: Integer
140
+ caplen = interface.snaplen.zero? ? original_length : [original_length, interface.snaplen].min
141
+ Format.check_lengths(caplen, original_length, interface.snaplen)
142
+ raise FileFormatError, 'invalid pcapng simple packet length' unless body.bytesize == 4 + Format.padded(caplen)
143
+
144
+ data = body.byteslice(4, caplen) #: String
145
+ make_packet(data, interface, original_length, 0, nil, {})
146
+ end
147
+
148
+ # @rbs (String body) -> void
149
+ def read_statistics(body)
150
+ require_size(body, 12)
151
+ index = body.unpack1(@integer_format) #: Integer
152
+ interface = interface_at(index)
153
+ opts = options(body, 12)
154
+ values = {} #: Hash[Symbol, Integer]
155
+ { received: 4, dropped: 5, if_dropped: 7, captured: 8 }.each do |name, code|
156
+ option = opts[code]&.first
157
+ next unless option
158
+ raise FileFormatError, 'invalid pcapng statistics counter' unless option.bytesize == 8
159
+
160
+ count = option.unpack1(@integer_format == 'V' ? 'Q<' : 'Q>') #: Integer
161
+ values[name] = count
162
+ end
163
+ @statistics[interface.object_id] = values
164
+ totals = %i[received dropped if_dropped captured].to_h { |name| [name, @statistics.values.sum { |entry| entry.fetch(name, 0) }] }
165
+ %i[received captured].each do |name|
166
+ totals[name] = @packet_counts.values.sum unless @statistics.values.any? { |entry| entry.key?(name) }
167
+ end
168
+ @stats = Capture::Stats.new(**totals)
169
+ end
170
+
171
+ # @rbs (String data, Capture::Interface interface, Integer original_length, Integer timestamp_ns, Symbol? direction, Hash[Symbol, untyped] meta) -> Packet
172
+ def make_packet(data, interface, original_length, timestamp_ns, direction, meta)
173
+ data = Format.normalize_null(data, interface.linktype, @integer_format)
174
+ @number += 1
175
+ @packet_counts[interface.object_id] = @packet_counts.fetch(interface.object_id, 0) + 1
176
+ Packet.new(data, timestamp_ns:, original_length:, linktype: interface.linktype, interface:, direction:, number: @number, meta:)
177
+ end
178
+
179
+ # @rbs (Integer index) -> Capture::Interface
180
+ def interface_at(index)
181
+ @section_interfaces.fetch(index) { raise FileFormatError, "pcapng references unknown interface #{index}" }
182
+ end
183
+
184
+ # @rbs (String body, Integer offset) -> Hash[Integer, Array[String]]
185
+ def options(body, offset)
186
+ result = {} #: Hash[Integer, Array[String]]
187
+ while offset < body.bytesize
188
+ require_size(body, offset + 4)
189
+ code, length = body.unpack(@integer_format == 'V' ? 'vv' : 'nn', offset:) #: [Integer, Integer]
190
+ offset += 4
191
+ if code.zero?
192
+ raise FileFormatError, 'invalid pcapng end of options' unless length.zero?
193
+ break
194
+ end
195
+ require_size(body, offset + Format.padded(length))
196
+ value = body.byteslice(offset, length) #: String
197
+ (result[code] ||= []) << value
198
+ offset += Format.padded(length)
199
+ end
200
+ result
201
+ end
202
+
203
+ # @rbs (String body, Integer size) -> void
204
+ def require_size(body, size)
205
+ raise FileFormatError, 'truncated pcapng block' if body.bytesize < size
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,143 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapngWriter
9
+ attr_reader :bytes_written, :path
10
+
11
+ # @rbs (untyped output, ?linktype: Integer | Symbol | String, ?snaplen: Integer, ?precision: Symbol, ?packet_buffered: bool, ?filter: String?, ?comment: String?, **untyped) -> void
12
+ def initialize(output, linktype: :ethernet, snaplen: 262_144, precision: :nano, packet_buffered: false,
13
+ filter: nil, comment: nil, **_options)
14
+ raise ArgumentError, 'snaplen must be between 1 and 16 MiB' unless snaplen.between?(1, Format::MAX_RECORD)
15
+
16
+ @linktype, @snaplen, @packet_buffered = Capture::Linktype.resolve(linktype), snaplen, packet_buffered
17
+ @filter, @comment = filter, comment
18
+ @owned = output.is_a?(String) && output != '-'
19
+ @path = output.is_a?(String) ? output : nil
20
+ @io = output.is_a?(String) ? (output == '-' ? $stdout : ::File.open(output, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600)) : output
21
+ @io.chmod(0o600) if @owned
22
+ @io.binmode if @io.respond_to?(:binmode)
23
+ @bytes_written = 0
24
+ @interfaces, @counts, @start_times, @end_times, @statistics = {}, {}, {}, {}, {}
25
+ @closed = false
26
+ opts = { 3 => RUBY_PLATFORM, 4 => "Redhound #{VERSION}" }
27
+ opts[1] = comment if comment
28
+ write_block(0x0a0d0d0a, [0x1a2b3c4d, 1, 0, 0xffff_ffff_ffff_ffff].pack('VvvQ<') + options(opts))
29
+ end
30
+
31
+ # @rbs (Packet packet) -> self
32
+ def write(packet)
33
+ raise IOError, 'writer is closed' if @closed
34
+ raise FileFormatError, 'pcapng timestamp must be nonnegative' if packet.timestamp_ns.negative?
35
+
36
+ index = ensure_interface(packet)
37
+ data = packet.data.byteslice(0, @snaplen)
38
+ flags = packet_flags(packet)
39
+ opts = { 2 => [flags].pack('V') }
40
+ opts[1] = packet.meta[:comment].to_s if packet.meta[:comment]
41
+ high, low = packet.timestamp_ns >> 32, packet.timestamp_ns & 0xffff_ffff
42
+ raise FileFormatError, 'pcapng timestamp exceeds unsigned 64-bit range' if high > 0xffff_ffff
43
+
44
+ body = [index, high, low, data.bytesize, packet.original_length].pack('V5') + Format.pad(data) + options(opts)
45
+ write_block(6, body)
46
+ @counts[index] = @counts.fetch(index, 0) + 1
47
+ @start_times[index] ||= packet.timestamp_ns
48
+ @end_times[index] = packet.timestamp_ns
49
+ @io.flush if @packet_buffered
50
+ self
51
+ end
52
+
53
+ alias << write
54
+
55
+ # @rbs (Packet packet) -> Integer
56
+ def record_size(packet)
57
+ 44 + Format.padded([packet.caplen, @snaplen].min) + (packet.meta[:comment] ? 4 + Format.padded(packet.meta[:comment].to_s.bytesize) : 0)
58
+ end
59
+
60
+ # @rbs (Capture::Stats stats, ?interface: Capture::Interface?) -> void
61
+ def write_stats(stats, interface: nil)
62
+ if interface
63
+ @interfaces.each { |key, index| @statistics[index] = stats if key[0] == interface.object_id }
64
+ elsif @interfaces.size == 1
65
+ @statistics[0] = stats
66
+ elsif @interfaces.size > 1
67
+ # Source statistics describe the entire capture; record them once to avoid double counting.
68
+ @statistics[0] = stats
69
+ end
70
+ end
71
+
72
+ # @rbs () -> void
73
+ def flush = @io.flush
74
+
75
+ # @rbs () -> void
76
+ def close
77
+ return if @closed
78
+
79
+ @interfaces.each_value do |index|
80
+ stats = @statistics[index]
81
+ start_time, end_time = @start_times.fetch(index, 0), @end_times.fetch(index, 0)
82
+ opts = { 2 => timestamp_words(start_time), 3 => timestamp_words(end_time),
83
+ 8 => [@counts.fetch(index, 0)].pack('Q<') }
84
+ if stats
85
+ opts[4], opts[5], opts[7] = [stats.received].pack('Q<'), [stats.dropped].pack('Q<'), [stats.if_dropped].pack('Q<')
86
+ opts[6] = [stats.captured].pack('Q<')
87
+ end
88
+ write_block(5, [index].pack('V') + timestamp_words(end_time) + options(opts))
89
+ end
90
+ @io.flush
91
+ @io.close if @owned
92
+ @closed = true
93
+ end
94
+
95
+ private
96
+
97
+ # @rbs (Packet packet) -> Integer
98
+ def ensure_interface(packet)
99
+ interface = packet.interface
100
+ key = [interface&.object_id || 0, packet.linktype]
101
+ return @interfaces[key] if @interfaces.key?(key)
102
+
103
+ index = @interfaces.size
104
+ @interfaces[key] = index
105
+ opts = { 2 => interface&.name || "interface#{index}", 9 => "\x09".b, 12 => RUBY_PLATFORM }
106
+ opts[3] = interface.description if interface&.description
107
+ expression = interface&.filter || @filter
108
+ opts[11] = "\0" + expression if expression
109
+ write_block(1, [packet.linktype, 0, @snaplen].pack('vvV') + options(opts))
110
+ index
111
+ end
112
+
113
+ # @rbs (Packet packet) -> Integer
114
+ def packet_flags(packet)
115
+ direction = { in: 1, out: 2 }.fetch(packet.direction, 0)
116
+ pkttype = packet.meta[:pkttype]
117
+ direction = pkttype == 4 ? 2 : 1 if direction.zero? && pkttype
118
+ reception = { 0 => 1, 1 => 3, 2 => 2, 3 => 4 }.fetch(pkttype, 0)
119
+ direction | (reception << 2)
120
+ end
121
+
122
+ # @rbs (Integer timestamp) -> String
123
+ def timestamp_words(timestamp) = [timestamp >> 32, timestamp & 0xffff_ffff].pack('V2')
124
+
125
+ # @rbs (Hash[Integer, String] values) -> String
126
+ def options(values)
127
+ values.map do |code, value|
128
+ raise FileFormatError, 'pcapng option exceeds 65535 bytes' if value.bytesize > 65_535
129
+
130
+ [code, value.bytesize].pack('vv') + Format.pad(value.b)
131
+ end.join.b + "\0" * 4
132
+ end
133
+
134
+ # @rbs (Integer type, String body) -> void
135
+ def write_block(type, body)
136
+ length = body.bytesize + 12
137
+ raise FileFormatError, 'pcapng block exceeds 16 MiB' if length > Format::MAX_RECORD
138
+
139
+ @bytes_written += @io.write([type, length].pack('V2') + body + [length].pack('V'))
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,98 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'shellwords'
5
+
6
+ module Redhound
7
+ # @api private
8
+ module File
9
+ # @api private
10
+ class RotatingWriter
11
+ attr_reader :path
12
+
13
+ # @rbs (String path, ?max_bytes: Integer?, ?interval: Numeric?, ?file_count: Integer?, ?post_rotate_command: String?, **untyped options) -> void
14
+ def initialize(path, max_bytes: nil, interval: nil, file_count: nil, post_rotate_command: nil, **options)
15
+ raise ArgumentError, 'cannot rotate standard output' if path == '-'
16
+ raise ArgumentError, 'max_bytes must be positive' if max_bytes && max_bytes <= 0
17
+ raise ArgumentError, 'interval must be positive' if interval && interval <= 0
18
+ raise ArgumentError, 'file_count must be positive' if file_count && file_count <= 0
19
+
20
+ @base, @max_bytes, @interval, @file_count = path, max_bytes, interval, file_count
21
+ @post_rotate_command, @options = post_rotate_command && Shellwords.split(post_rotate_command), options
22
+ raise ArgumentError, 'post-rotate command must not be empty' if @post_rotate_command&.empty?
23
+ @sequence, @packet_count, @closed = 0, 0, false
24
+ open_next
25
+ end
26
+
27
+ # @rbs (Packet packet) -> self
28
+ def write(packet)
29
+ raise IOError, 'writer is closed' if @closed
30
+
31
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @opened_at
32
+ size_due = @max_bytes && @writer.bytes_written >= @max_bytes
33
+ time_due = @interval && elapsed >= @interval
34
+ rotate if @packet_count.positive? && (size_due || time_due)
35
+ @writer.write(packet)
36
+ @packet_count += 1
37
+ self
38
+ end
39
+
40
+ alias << write
41
+
42
+ # @rbs (Capture::Stats stats, ?interface: Capture::Interface?) -> void
43
+ def write_stats(stats, interface: nil) = @writer.write_stats(stats, interface:)
44
+
45
+ # @rbs () -> Integer
46
+ def bytes_written = @writer.bytes_written
47
+
48
+ # @rbs () -> void
49
+ def flush = @writer.flush
50
+
51
+ # @rbs () -> void
52
+ def close
53
+ return if @closed
54
+
55
+ @writer.close
56
+ run_command
57
+ @closed = true
58
+ end
59
+
60
+ private
61
+
62
+ # @rbs () -> void
63
+ def rotate
64
+ @writer.close
65
+ run_command
66
+ @closed = true
67
+ @sequence += 1
68
+ if @interval && !@max_bytes && @file_count && @sequence >= @file_count
69
+ @closed = true
70
+ raise RotationComplete, 'rotation file count reached'
71
+ end
72
+ open_next
73
+ end
74
+
75
+ # @rbs () -> void
76
+ def open_next
77
+ sequence = @max_bytes && @file_count ? @sequence % @file_count : @sequence
78
+ base = @interval ? Time.now.strftime(@base) : @base
79
+ extension = ::File.extname(base)
80
+ stem = extension.empty? ? base : base.delete_suffix(extension)
81
+ @path = "#{stem}_#{format('%05d', sequence)}#{extension}"
82
+ @writer = Writer.open(@path, **@options)
83
+ @opened_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ @packet_count = 0
85
+ @closed = false
86
+ end
87
+
88
+ # @rbs () -> void
89
+ def run_command
90
+ return unless @post_rotate_command
91
+
92
+ pid = Process.spawn(*@post_rotate_command, @path)
93
+ _, status = Process.wait2(pid)
94
+ raise CaptureError, "post-rotate command failed: #{status.exitstatus}" unless status.success?
95
+ end
96
+ end
97
+ end
98
+ end