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
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 +
|
|
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
|
data/lib/redhound/analysis.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
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(
|
|
85
|
+
File::PcapngReader.new(reader_io, magic)
|
|
20
86
|
elsif File::Format::PCAP_MAGICS.key?(magic)
|
|
21
|
-
File::PcapReader.new(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/redhound/capture.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/redhound/cli/command.rb
CHANGED
|
@@ -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(
|
|
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) || []
|