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
@@ -22,7 +22,7 @@ module Redhound
22
22
  recognized = packet.layers.find { |item| (candidate = @registry.protocols[item.protocol]) && candidate <= StreamDissector }
23
23
  klass = recognized ? @registry.protocols[recognized.protocol] : flow.applications[1 - direction]&.class
24
24
  end
25
- flow.applications[direction] = klass.new(registry: @registry, max_bytes: @stream_bytes) if klass && klass <= StreamDissector
25
+ flow.applications[direction] = create_application(klass, flow, direction, layer) if klass && klass <= StreamDissector
26
26
  application = flow.applications[direction]
27
27
  end
28
28
  TcpAnalysis.update(flow, direction, stream, layer, packet.timestamp_ns)
@@ -47,7 +47,7 @@ module Redhound
47
47
  elsif flow.streams.compact.size == 2 && flow.streams.compact.all?(&:fin_seen?)
48
48
  finish_flow(flow, packet)
49
49
  elsif stream.fin
50
- attach(packet, application.on_close(flow, direction)) if application
50
+ attach(packet, invoke(flow, direction, application, :on_close)) if application
51
51
  flow.closed_ns ||= packet.timestamp_ns if flow.streams.compact.size == 2 && flow.streams.compact.all?(&:fin)
52
52
  end
53
53
  end
@@ -59,23 +59,16 @@ module Redhound
59
59
  gap = Layer.new(:tcp, 0, 0, 0).diagnose(:warning, :reassembly_gap, "#{origin} TCP bytes missing")
60
60
  packet&.[](:tcp)&.diagnose(:warning, :reassembly_gap, gap.diagnostics.first.message)
61
61
  completed << gap unless packet
62
- layers = application ? application.on_gap(flow, direction, origin) : Array.new #: Array[Layer]
62
+ flow.probes[direction].clear
63
+ flow.probe_disabled[direction] = false
64
+ application = flow.applications[direction]
65
+ layers = application ? invoke(flow, direction, application, :on_gap, origin) : Array.new #: Array[Layer]
63
66
  completed.concat(layers)
64
67
  attach(packet, layers) if packet
65
68
  next
66
69
  end
67
70
  @follow&.write(flow, direction, data)
68
- layers = if application
69
- begin
70
- application.on_data(flow, direction, data, origin)
71
- rescue StandardError => error
72
- raise if ENV['REDHOUND_STRICT'] == '1'
73
- application.on_gap(flow, direction, data.bytesize)
74
- [Layer.new(application.class.protocol_id, 0, 0, 0).diagnose(:error, :dissector_bug, "#{error.class}: #{error.message}")]
75
- end
76
- else
77
- Array.new
78
- end #: Array[Layer]
71
+ layers = application_data(flow, direction, data, origin, packet)
79
72
  attach(packet, layers) if packet
80
73
  completed.concat(layers)
81
74
  end
@@ -83,6 +76,63 @@ module Redhound
83
76
  ensure
84
77
  stream.clear_deliveries
85
78
  end
79
+ # @rbs (Flow flow, Integer direction, String data, Integer origin, Packet? packet) -> Array[Layer]
80
+ def application_data(flow, direction, data, origin, packet)
81
+ application = flow.applications[direction]
82
+ probe = flow.probes[direction]
83
+ unless application
84
+ return [] unless @protocol_streams && !flow.probe_disabled[direction]
85
+ sample = (probe.map(&:first).join + data.byteslice(0, 128)).byteslice(0, 128) #: String
86
+ ctx = Context.new(Packet.new(sample), registry: @registry)
87
+ ctx.layers << Layer.new(:tcp, 0, 0, sample.bytesize)
88
+ klass = @registry.heuristic(:tcp, ctx, ctx.cursor)
89
+ if klass && klass <= StreamDissector
90
+ diagnostic_layer = packet&.[](:tcp) || Layer.new(:tcp, 0, 0, 0)
91
+ application = flow.applications[direction] = create_application(klass, flow, direction, diagnostic_layer)
92
+ return packet ? [] : [diagnostic_layer] unless application
93
+ if packet
94
+ position = packet.layers.index { |layer| layer.protocol == :tcp && !layer.embedded }
95
+ packet.layers.slice!(position + 1, packet.layers.size) if position
96
+ end
97
+ else
98
+ if sample.bytesize < 128 && probe.sum { |bytes, _frame| bytes.bytesize * 2 + 128 } + data.bytesize * 2 + 128 <= @stream_bytes
99
+ probe << [data.dup, origin]
100
+ else
101
+ probe.clear
102
+ flow.probe_disabled[direction] = true
103
+ end
104
+ return []
105
+ end
106
+ end
107
+ chunks = probe + [[data, origin]]
108
+ probe.clear
109
+ chunks.flat_map { |bytes, frame| invoke(flow, direction, application, :on_data, bytes, frame) }
110
+ rescue StandardError => error
111
+ [application_error(flow, direction, :tcp, error)]
112
+ end
113
+ # @rbs (untyped klass, Flow flow, Integer direction, Layer layer) -> untyped
114
+ def create_application(klass, flow, direction, layer)
115
+ klass.new(registry: @registry, max_bytes: @stream_bytes)
116
+ rescue StandardError => error
117
+ layer.diagnostics.concat(application_error(flow, direction, klass.protocol_id, error).diagnostics)
118
+ nil
119
+ end
120
+ # @rbs (Flow flow, Integer direction, untyped application, Symbol method, *untyped arguments) -> Array[Layer]
121
+ def invoke(flow, direction, application, method, *arguments)
122
+ layers = application.public_send(method, flow, direction, *arguments)
123
+ raise TypeError, 'stream callback must return an array of Layers' unless layers.is_a?(Array) && layers.all? { |layer| layer.is_a?(Layer) }
124
+ layers
125
+ rescue StandardError => error
126
+ [application_error(flow, direction, application.class.protocol_id, error)]
127
+ end
128
+ # @rbs (Flow flow, Integer direction, Symbol protocol, StandardError error) -> Layer
129
+ def application_error(flow, direction, protocol, error)
130
+ raise error if ENV['REDHOUND_STRICT'] == '1'
131
+ flow.applications[direction] = nil
132
+ flow.probes[direction].clear
133
+ flow.probe_disabled[direction] = true
134
+ Layer.new(protocol, 0, 0, 0).diagnose(:error, :dissector_bug, "#{error.class}: #{error.message}")
135
+ end
86
136
  # @rbs (Packet packet, Array[Layer] layers) -> void
87
137
  def attach(packet, layers)
88
138
  layers.each do |layer|
@@ -100,21 +150,23 @@ module Redhound
100
150
  stream.flush_gap
101
151
  completed.concat(deliver(flow, direction, stream, application, packet))
102
152
  end
153
+ application = flow.applications[direction]
103
154
  if application
104
- layers = application.on_close(flow, direction)
155
+ layers = invoke(flow, direction, application, :on_close)
105
156
  attach(packet, layers) if packet
106
157
  completed.concat(layers)
107
158
  end
108
159
  stream.release
109
160
  end
110
161
  flow.http_methods.each(&:clear)
162
+ flow.probes.each(&:clear)
111
163
  flow.closed_ns ||= packet ? packet.timestamp_ns : flow.last_ns
112
164
  completed
113
165
  end
114
166
  # @rbs (FlowTable flows, Packet packet) -> void
115
167
  def enforce_limit(flows, packet)
116
168
  while flows.tcp_bytesize > @max_bytes
117
- flow = flows.evict
169
+ flow = flows.evict(:tcp)
118
170
  break unless flow
119
171
  packet.layers.last.diagnose(:warning, :reassembly_gap, 'TCP reassembly memory limit exceeded')
120
172
  end
@@ -21,6 +21,9 @@ module Redhound
21
21
  # @rbs (Integer sequence, String data, ?syn: bool, ?fin: bool, ?frame: Integer) -> Array[String]
22
22
  def push(sequence, data, syn: false, fin: false, frame: 0)
23
23
  @diagnostics, @deliveries, @gap_lengths = [], [], []
24
+ if @base_seq && ((syn && sequence == @base_seq) || (fin && Util::Seq.lt((sequence + data.bytesize) % Util::Seq::MOD, next_sequence)))
25
+ @diagnostics << :retransmission
26
+ end
24
27
  unless @base_seq
25
28
  @base_seq = sequence
26
29
  @expected = syn ? 1 : 0
@@ -115,7 +118,9 @@ module Redhound
115
118
  # @rbs () -> void
116
119
  def trim_history
117
120
  # Previously delivered bytes share the stream's bounded budget with holes.
118
- limit = [@max_bytes - @queue.sum { |item| item[1].bytesize + 128 }, 0].max
121
+ # Ruby grows mutable String buffers geometrically. Reserve twice their
122
+ # length plus object storage so capacity also stays inside the budget.
123
+ limit = [(@max_bytes - @queue.sum { |item| item[1].bytesize * 2 + 168 } - 40) / 2, 0].max
119
124
  excess = @history.bytesize - limit
120
125
  if excess.positive?
121
126
  @history = @history.byteslice(excess..) #: String
@@ -123,7 +128,7 @@ module Redhound
123
128
  end
124
129
  end
125
130
  # @rbs () -> Integer
126
- def bytesize = @history.bytesize + @queue.sum { |item| item[1].bytesize + 128 }
131
+ def bytesize = (@history.empty? ? 0 : @history.bytesize * 2 + 40) + @queue.sum { |item| item[1].bytesize * 2 + 168 }
127
132
  # @rbs () -> bool
128
133
  def pending? = !@queue.empty? || (!@fin && !@fin_offset.nil?)
129
134
  # @rbs () -> bool
@@ -28,7 +28,16 @@ module Redhound
28
28
  @follow = follow ? Output::Follow.new(follow) : nil
29
29
  @tcp_reassembler = TcpReassembler.new(registry: registry, max_bytes: [tcp_bytes, max_state_bytes].min,
30
30
  stream_bytes: stream_bytes, protocol_streams: protocol_streams, follow: @follow)
31
- @flows.on_remove = ->(flow) { @tcp_reassembler.finish_flow(flow) }
31
+ @removed_gaps, @removed_incomplete, @removed_bugs = 0, 0, 0
32
+ @flows.on_remove = lambda do |flow|
33
+ @tcp_reassembler.finish_flow(flow).each do |layer|
34
+ layer.diagnostics.each do |diagnostic|
35
+ @removed_gaps += 1 if diagnostic.code == :reassembly_gap
36
+ @removed_incomplete += 1 if diagnostic.code == :truncated
37
+ @removed_bugs += 1 if diagnostic.code == :dissector_bug
38
+ end
39
+ end
40
+ end
32
41
  specs = stats.uniq
33
42
  raise ConfigurationError, 'too many statistics specifications (maximum 64)' if specs.size > 64
34
43
  row_limit = [100_000, [((128 << 20) / [specs.size, 1].max) / 1024, 1].max].min
@@ -103,6 +112,9 @@ module Redhound
103
112
  # @rbs (untyped err) -> void
104
113
  def snapshot(err)
105
114
  @statistics.each { |stat| stat.format(err) }
115
+ err.puts("#{@removed_gaps} TCP reassembly_gap events during flow removal") if @removed_gaps.positive?
116
+ err.puts("#{@removed_incomplete} truncated application PDUs during flow removal") if @removed_incomplete.positive?
117
+ err.puts("#{@removed_bugs} dissector_bug events during flow removal") if @removed_bugs.positive?
106
118
  err.puts("#{@flows.evicted} flows evicted; #{@flows.expired} flows expired") if @flows.evicted.positive? || @flows.expired.positive?
107
119
  err.puts("#{@ip_reassembler.evicted} fragmented datagrams evicted; #{@ip_reassembler.expired} expired") if @ip_reassembler.evicted.positive? || @ip_reassembler.expired.positive?
108
120
  end
@@ -118,11 +130,27 @@ module Redhound
118
130
  end
119
131
  @flows.account(flow)
120
132
  end
133
+ incomplete = @ip_reassembler.finish
134
+ err.puts("#{incomplete} incomplete IP datagrams: reassembly_gap at EOF") if incomplete.positive?
121
135
  @follow&.format(out)
122
136
  snapshot(err)
123
137
  ensure
138
+ original_error = $!
124
139
  @finished = true
125
- @follow&.close
140
+ @flows.values.each do |flow|
141
+ flow.streams.compact.each(&:release)
142
+ flow.applications.fill(nil)
143
+ flow.http_methods.each(&:clear)
144
+ flow.probes.each(&:clear)
145
+ flow.closed_ns ||= flow.last_ns
146
+ @flows.account(flow)
147
+ end
148
+ @ip_reassembler.finish
149
+ begin
150
+ @follow&.close
151
+ rescue StandardError
152
+ raise unless original_error
153
+ end
126
154
  end
127
155
  end
128
156
  end
@@ -51,7 +51,10 @@ module Redhound
51
51
  # @rbs (?timeout: Numeric?) -> Packet?
52
52
  def next_packet(timeout: nil)
53
53
  deadline = deadline_for(timeout)
54
+ polled = false
54
55
  until @stopped || @closed
56
+ return nil if polled && deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
57
+ polled = true
55
58
  if @offset >= @records.bytesize
56
59
  return nil unless wait_readable(@device, deadline)
57
60
  result = @device.read_nonblock(@buffer_size, exception: false)
@@ -0,0 +1,41 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ module Capture
6
+ module Bsd
7
+ # macOS interface metadata can be queried without opening a BPF device.
8
+ module Ifreq
9
+ SIOCGIFMTU = 0xc0206933 # Verified against sys/sockio.h; sizeof(ifreq) is 32.
10
+
11
+ # @rbs (String name, index: Integer, flags: Integer, link_address: String?) -> Interface
12
+ def self.interface(name, index:, flags:, link_address:)
13
+ raise ArgumentError, 'invalid interface name' if name.bytesize >= 16 || name.include?("\0")
14
+
15
+ socket = ::Socket.new(::Socket::AF_INET, ::Socket::SOCK_DGRAM, 0)
16
+ bytes = [name].pack('a16') + "\0" * 16
17
+ begin
18
+ socket.ioctl(SIOCGIFMTU, bytes)
19
+ ensure
20
+ socket.close
21
+ end
22
+ mtu = bytes.unpack1('i', offset: 16) #: Integer
23
+ mac = nil # @rbs String?
24
+ if link_address && link_address.bytesize >= 8
25
+ name_length = link_address.getbyte(5) #: Integer
26
+ address_length = link_address.getbyte(6) #: Integer
27
+ if address_length == 6 && link_address.bytesize >= 8 + name_length + address_length
28
+ mac_bytes = link_address.byteslice(8 + name_length, address_length) #: String
29
+ mac_values = mac_bytes.unpack('C6') #: Array[Integer]
30
+ mac = mac_values.map { |value| format('%02x', value) }.join(':')
31
+ end
32
+ end
33
+ linktype = (flags & 8).positive? || name.start_with?('utun') ? 0 : 1
34
+ Interface.new(name:, index:, flags:, mtu:, mac:, linktype:)
35
+ rescue Errno::ENXIO, Errno::ENODEV
36
+ raise InterfaceNotFound, "interface #{name.inspect} not found"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -6,6 +6,61 @@ module Redhound
6
6
  module Capture
7
7
  # @api private
8
8
  class FileSource < Source
9
+ class ReadStopped < StandardError; end
10
+
11
+ # @api private
12
+ class FileInput
13
+ # @rbs (::File io) -> void
14
+ def initialize(io)
15
+ @io, @buffer, @offset = io, ''.b, 0
16
+ end
17
+
18
+ # @rbs (Integer length) -> String?
19
+ def read(length)
20
+ ending = @offset + length
21
+ if ending <= @buffer.bytesize
22
+ result = @buffer.byteslice(@offset, length)
23
+ @offset = ending
24
+ return result
25
+ end
26
+ result = @buffer.byteslice(@offset..) || ''.b
27
+ while result.bytesize < length
28
+ begin
29
+ @buffer = @io.readpartial(65_536)
30
+ rescue EOFError
31
+ @buffer, @offset = ''.b, 0
32
+ return result.empty? ? nil : result
33
+ end
34
+ amount = [length - result.bytesize, @buffer.bytesize].min #: Integer
35
+ result << @buffer.byteslice(0, amount)
36
+ @offset = amount
37
+ end
38
+ result
39
+ end
40
+ end
41
+ private_constant :FileInput
42
+
43
+ # Nonregular inputs need bounded polling so Source#stop can interrupt an idle pipe.
44
+ class StreamInput
45
+ # @rbs (IO io, Source source) -> void
46
+ def initialize(io, source)
47
+ @io, @source = io, source
48
+ end
49
+
50
+ # @rbs (Integer length) -> String?
51
+ def read(length)
52
+ loop do
53
+ raise ReadStopped if @source.stopped?
54
+ next unless IO.select([@io], nil, nil, 0.1)
55
+
56
+ part = @io.read_nonblock(length, exception: false)
57
+ next if part == :wait_readable
58
+
59
+ return part
60
+ end
61
+ end
62
+ end
63
+
9
64
  attr_reader :reader
10
65
 
11
66
  # @rbs (untyped input, ?filter: String?) -> void
@@ -14,15 +69,27 @@ module Redhound
14
69
  @owned = input.is_a?(String) && input != '-'
15
70
  @io = input.is_a?(String) ? (input == '-' ? $stdin : ::File.open(input, 'rb')) : input
16
71
  @io.binmode if @io.respond_to?(:binmode)
17
- magic = File::Format.read_exact(@io, 4)
72
+ regular = @io.is_a?(IO) && @io.stat.file?
73
+ reader_io = if @owned && regular
74
+ FileInput.new(@io)
75
+ elsif @io.is_a?(IO) && !regular
76
+ StreamInput.new(@io, self)
77
+ else
78
+ @io
79
+ end
80
+ @stream = reader_io.is_a?(StreamInput)
81
+ @packets = nil # @rbs Thread::SizedQueue[Packet | StandardError]?
82
+ @reader_thread = nil # @rbs Thread?
83
+ magic = File::Format.read_exact(reader_io, 4)
18
84
  @reader = if magic == File::Format::PCAPNG_MAGIC
19
- File::PcapngReader.new(@io, magic)
85
+ File::PcapngReader.new(reader_io, magic)
20
86
  elsif File::Format::PCAP_MAGICS.key?(magic)
21
- File::PcapReader.new(@io, magic)
87
+ File::PcapReader.new(reader_io, magic)
22
88
  else
23
89
  raise FileFormatError, 'unrecognized capture file magic'
24
90
  end
25
91
  @filter_expression = filter
92
+ @filter_program = nil # @rbs Filter::Program?
26
93
  @filters = {} #: Hash[Integer, untyped]
27
94
  @filters[@reader.linktype] = Filter.compile(filter, linktype: @reader.linktype) if filter
28
95
  rescue StandardError, Interrupt
@@ -47,8 +114,18 @@ module Redhound
47
114
  def next_packet(timeout: nil)
48
115
  return nil if @stopped || @closed
49
116
 
50
- while (packet = @reader.next_packet)
117
+ deadline = deadline_for(timeout)
118
+ polled = false
119
+ until @stopped || @closed
120
+ return nil if polled && deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
121
+ polled = true
122
+ remaining = deadline && [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
123
+ packet = read_packet(remaining)
124
+ return nil if packet == :wait_readable
125
+ break unless packet
126
+
51
127
  @capture_stats.received += 1
128
+ next if @filter_program && !@filter_program.match?(packet)
52
129
  if @filter_expression
53
130
  program = (@filters[packet.linktype] ||= Filter.compile(@filter_expression, linktype: packet.linktype))
54
131
  next unless program.match?(packet)
@@ -58,6 +135,15 @@ module Redhound
58
135
  end
59
136
  stop
60
137
  nil
138
+ rescue ReadStopped
139
+ nil
140
+ end
141
+
142
+ # @rbs (Filter::Program? program) -> void
143
+ def attach_filter(program)
144
+ @filter_expression = nil
145
+ @filters.clear
146
+ @filter_program = program
61
147
  end
62
148
 
63
149
  # @rbs () -> Integer
@@ -71,13 +157,58 @@ module Redhound
71
157
  @reader.respond_to?(:statistics?) && @reader.statistics? ? @reader.stats : super
72
158
  end
73
159
 
160
+ # @rbs () -> void
161
+ def stop
162
+ super
163
+ @packets&.close
164
+ end
165
+
74
166
  # @rbs () -> void
75
167
  def close
76
168
  return if @closed
77
169
 
78
170
  super
171
+ @reader_thread&.join(0.2)
79
172
  @io.close if @owned
80
173
  end
174
+
175
+ private
176
+
177
+ # A single bounded worker preserves partial records across caller timeouts.
178
+ # @rbs (Numeric? timeout) -> (Packet | nil | :wait_readable)
179
+ def read_packet(timeout)
180
+ return @reader.next_packet unless @stream
181
+
182
+ unless @packets
183
+ queue = Thread::SizedQueue.new(1) #: Thread::SizedQueue[Packet | StandardError]
184
+ @packets = queue
185
+ @reader_thread = Thread.new do
186
+ begin
187
+ while !stopped? && (packet = @reader.next_packet)
188
+ queue << packet
189
+ end
190
+ rescue ReadStopped, ClosedQueueError
191
+ nil
192
+ rescue StandardError => error
193
+ begin
194
+ queue << error
195
+ rescue ClosedQueueError
196
+ nil
197
+ end
198
+ ensure
199
+ queue.close
200
+ end
201
+ end
202
+ end
203
+ queue = @packets #: Thread::SizedQueue[Packet | StandardError]
204
+ result = queue.pop(timeout: timeout && Float(timeout))
205
+ raise result if result.is_a?(StandardError)
206
+
207
+ return result if result
208
+ return nil if queue.closed?
209
+
210
+ :wait_readable
211
+ end
81
212
  end
82
213
  end
83
214
  end
@@ -37,11 +37,15 @@ module Redhound
37
37
 
38
38
  # @rbs () -> Array[Interface]
39
39
  def self.all
40
- Socket.getifaddrs.uniq(&:name).map do |address|
40
+ addresses = Socket.getifaddrs
41
+ addresses.uniq(&:name).map do |address|
41
42
  index = address.ifindex || 0
42
43
  interface = new(name: address.name, index:, flags: address.flags)
43
44
  if RUBY_PLATFORM.include?('linux')
44
45
  Linux::Ifreq.interface(address.name, index:)
46
+ elsif RUBY_PLATFORM.include?('darwin')
47
+ link = addresses.find { |entry| entry.name == address.name && entry.addr&.afamily == 18 }
48
+ Bsd::Ifreq.interface(address.name, index:, flags: address.flags, link_address: link&.addr&.to_sockaddr)
45
49
  else
46
50
  interface
47
51
  end
@@ -32,6 +32,7 @@ module Redhound
32
32
  end
33
33
  @socket.bind(SockaddrLL.new(index: @interface.index).to_sockaddr)
34
34
  prime_timestamp
35
+ warn_vlan_offload
35
36
  if direction == :in
36
37
  begin
37
38
  @socket.setsockopt(Constants::SOL_PACKET, Constants::PACKET_IGNORE_OUTGOING, [1].pack('i'))
@@ -50,11 +51,16 @@ module Redhound
50
51
  # @rbs (?timeout: Numeric?) -> Packet?
51
52
  def next_packet(timeout: nil)
52
53
  deadline = deadline_for(timeout)
54
+ polled = false
53
55
  until @stopped || @closed
54
- return nil unless wait_readable(@socket, deadline)
55
-
56
+ return nil if polled && deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
57
+ polled = true
56
58
  result = @socket.recvfrom_nonblock(Constants::RECEIVE_SIZE, exception: false)
57
- next if result == :wait_readable
59
+ if result == :wait_readable
60
+ return nil unless wait_readable(@socket, deadline)
61
+
62
+ next
63
+ end
58
64
 
59
65
  data, address_info = result
60
66
  address = SockaddrLL.parse(address_info.to_sockaddr)
@@ -78,10 +84,7 @@ module Redhound
78
84
  def attach_filter(program)
79
85
  instructions = program ? program.instructions : [[0x06, 0, 0, receive_snaplen]] #: Array[[Integer, Integer, Integer, Integer]]
80
86
  Filter::BPF::Validator.validate!(instructions)
81
- instructions = instructions.map do |instruction|
82
- code, jt, jf, k = instruction #: [Integer, Integer, Integer, Integer]
83
- [code, jt, jf, code == 0x06 && k.positive? ? receive_snaplen : k]
84
- end
87
+ instructions = receive_filter(instructions)
85
88
  packed = instructions.map { |instruction| instruction.pack('S C C L') }.join
86
89
  @socket.setsockopt(::Socket::SOL_SOCKET, Constants::SO_ATTACH_FILTER, [instructions.size, packed].pack('S x6 P'))
87
90
  end
@@ -112,6 +115,47 @@ module Redhound
112
115
 
113
116
  protected
114
117
 
118
+ # Positive filter returns select packets; the source applies its configured snaplen.
119
+ # @rbs (Array[[Integer, Integer, Integer, Integer]] instructions) -> Array[[Integer, Integer, Integer, Integer]]
120
+ def receive_filter(instructions)
121
+ unless instructions.any? { |instruction| instruction[0] == 0x16 }
122
+ return instructions.map do |code, jt, jf, k|
123
+ [code, jt, jf, code == 0x06 && k.positive? ? receive_snaplen : k]
124
+ end
125
+ end
126
+
127
+ assembler = Filter::BPF::Assembler.new
128
+ labels = instructions.map { assembler.label }
129
+ accepted, rejected = assembler.label, assembler.label
130
+ instructions.each_with_index do |(code, jt, jf, k), index|
131
+ assembler.mark(labels.fetch(index))
132
+ if code == 0x16
133
+ assembler.branch(0x15, 0, rejected, accepted)
134
+ elsif code == 5
135
+ assembler.jump(labels.fetch(index + 1 + k))
136
+ elsif Filter::BPF::Validator::JUMPS.include?(code)
137
+ assembler.branch(code, k, labels.fetch(index + 1 + jt), labels.fetch(index + 1 + jf))
138
+ else
139
+ assembler.emit(code, code == 6 && k.positive? ? receive_snaplen : k)
140
+ end
141
+ end
142
+ assembler.mark(accepted)
143
+ assembler.emit(6, receive_snaplen)
144
+ assembler.mark(rejected)
145
+ assembler.emit(6, 0)
146
+ result = assembler.assemble
147
+ Filter::BPF::Validator.validate!(result)
148
+ result
149
+ end
150
+
151
+ # @rbs () -> void
152
+ def warn_vlan_offload
153
+ return unless instance_of?(PacketSocket) && [1, 276].include?(@linktype) && !@interface.loopback?
154
+
155
+ name = @interface.name == 'any' ? '<interface>' : @interface.name
156
+ warn "redhound: socket capture cannot restore stripped VLAN tags; disable receive VLAN offload with ethtool -K #{name} rxvlan off on the receiving interface"
157
+ end
158
+
115
159
  # @rbs () -> void
116
160
  def close_resources
117
161
  @socket.close if @socket && !@socket.closed?
@@ -135,8 +179,8 @@ module Redhound
135
179
  @socket.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVBUF, [buffer_size].pack('i'))
136
180
  begin
137
181
  @socket.setsockopt(::Socket::SOL_SOCKET, Constants::SO_RCVBUFFORCE, [buffer_size].pack('i'))
138
- rescue Errno::EPERM, Errno::EACCES, Errno::ENOPROTOOPT
139
- # SO_RCVBUF already applied without CAP_NET_ADMIN.
182
+ rescue Errno::EPERM, Errno::EACCES, Errno::ENOPROTOOPT => error
183
+ warn "redhound: SO_RCVBUFFORCE unavailable (#{error.message}); using SO_RCVBUF with the system limit"
140
184
  end
141
185
  end
142
186
 
@@ -11,7 +11,10 @@ module Redhound
11
11
  # @rbs (?timeout: Numeric?) -> Packet?
12
12
  def next_packet(timeout: nil)
13
13
  deadline = deadline_for(timeout)
14
+ polled = false
14
15
  until @stopped || @closed
16
+ return nil if polled && deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
17
+ polled = true
15
18
  unless @pending_count.positive?
16
19
  base = @current_block * @block_size
17
20
  unless (u32(base + 8) & Constants::TP_STATUS_USER).positive?
@@ -102,6 +105,8 @@ module Redhound
102
105
  begin
103
106
  Warning[:experimental] = false
104
107
  @ring = IO::Buffer.map(@socket, @block_size * @block_count, 0)
108
+ rescue ArgumentError => error
109
+ raise UnsupportedPlatform, "Ruby #{RUBY_VERSION} IO::Buffer.map cannot map a packet socket: #{error.message}"
105
110
  ensure
106
111
  Warning[:experimental] = experimental
107
112
  end
@@ -12,6 +12,7 @@ require_relative 'capture/linux/cooked'
12
12
  require_relative 'capture/linux/packet_socket'
13
13
  require_relative 'capture/linux/tpacket_v3'
14
14
  require_relative 'capture/bsd/constants'
15
+ require_relative 'capture/bsd/ifreq'
15
16
  require_relative 'capture/bsd/bpf_device'
16
17
  require_relative 'file/format'
17
18
  require_relative 'file/pcap_reader'
@@ -35,10 +36,10 @@ module Redhound
35
36
  when :socket then Linux::PacketSocket.new(interface:, **options)
36
37
  when :ring then Linux::TPacketV3.new(interface:, **options)
37
38
  when :auto
38
- if RUBY_PLATFORM.include?('x86_64')
39
+ if RUBY_PLATFORM.start_with?('x86_64-', 'aarch64-', 'arm64-')
39
40
  begin
40
41
  Linux::TPacketV3.new(interface:, **options)
41
- rescue SystemCallError, IO::Buffer::AccessError, IO::Buffer::AllocationError, NotImplementedError => error
42
+ rescue SystemCallError, IO::Buffer::AccessError, IO::Buffer::AllocationError, NotImplementedError, UnsupportedPlatform => error
42
43
  warn "redhound: ring backend unavailable (#{error.message}); using socket"
43
44
  Linux::PacketSocket.new(interface:, **options)
44
45
  end
@@ -16,7 +16,7 @@ module Redhound
16
16
  elsif values[:version]
17
17
  out.puts("Redhound #{VERSION}")
18
18
  elsif values[:list_interfaces]
19
- Capture.interfaces.each { |i| out.puts("#{i.index}.#{i.name} [#{i.flags & 1 != 0 ? 'Up' : 'Down'}]#{i.mac ? " #{i.mac}" : ''}#{i.mtu ? " mtu #{i.mtu}" : ''}") }
19
+ Capture.interfaces.each { |i| out.puts(i.to_s) }
20
20
  elsif values[:list_protocols]
21
21
  Registry.default.protocols.each_value do |klass|
22
22
  fields = klass.compiled_header&.definitions&.map(&:name) || []