redhound 1.0.1 → 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 (232) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +97 -31
  4. data/docs/API.md +104 -0
  5. data/docs/FILTERS.md +37 -0
  6. data/docs/MIGRATION.md +23 -0
  7. data/docs/PROTOCOLS.md +54 -0
  8. data/docs/USAGE.md +86 -0
  9. data/docs/VALIDATION.md +175 -0
  10. data/docs/json-schema.json +64 -0
  11. data/exe/redhound +2 -1
  12. data/lib/redhound/analysis/flow_key.rb +59 -0
  13. data/lib/redhound/analysis/flow_table.rb +124 -0
  14. data/lib/redhound/analysis/ip_reassembler.rb +209 -0
  15. data/lib/redhound/analysis/stats.rb +150 -0
  16. data/lib/redhound/analysis/tcp_analysis.rb +43 -0
  17. data/lib/redhound/analysis/tcp_reassembler.rb +176 -0
  18. data/lib/redhound/analysis/tcp_stream.rb +156 -0
  19. data/lib/redhound/analysis.rb +157 -0
  20. data/lib/redhound/capture/bsd/bpf_device.rb +155 -0
  21. data/lib/redhound/capture/bsd/constants.rb +29 -0
  22. data/lib/redhound/capture/bsd/ifreq.rb +41 -0
  23. data/lib/redhound/capture/file_source.rb +214 -0
  24. data/lib/redhound/capture/interface.rb +70 -0
  25. data/lib/redhound/capture/linktype.rb +30 -0
  26. data/lib/redhound/capture/linux/constants.rb +37 -0
  27. data/lib/redhound/capture/linux/cooked.rb +18 -0
  28. data/lib/redhound/capture/linux/ifreq.rb +42 -0
  29. data/lib/redhound/capture/linux/packet_socket.rb +224 -0
  30. data/lib/redhound/capture/linux/sockaddr_ll.rb +33 -0
  31. data/lib/redhound/capture/linux/tpacket_v3.rb +137 -0
  32. data/lib/redhound/capture/source.rb +79 -0
  33. data/lib/redhound/capture/stats.rb +22 -0
  34. data/lib/redhound/capture.rb +70 -0
  35. data/lib/redhound/cli/command.rb +42 -0
  36. data/lib/redhound/cli/options.rb +133 -0
  37. data/lib/redhound/cli/privileges.rb +29 -0
  38. data/lib/redhound/cli/runner.rb +129 -0
  39. data/lib/redhound/context.rb +20 -0
  40. data/lib/redhound/cursor.rb +69 -0
  41. data/lib/redhound/diagnostic.rb +7 -0
  42. data/lib/redhound/dissector.rb +152 -0
  43. data/lib/redhound/engine.rb +69 -0
  44. data/lib/redhound/errors.rb +38 -0
  45. data/lib/redhound/field.rb +62 -0
  46. data/lib/redhound/file/format.rb +63 -0
  47. data/lib/redhound/file/pcap_reader.rb +43 -0
  48. data/lib/redhound/file/pcap_writer.rb +79 -0
  49. data/lib/redhound/file/pcapng_reader.rb +221 -0
  50. data/lib/redhound/file/pcapng_writer.rb +151 -0
  51. data/lib/redhound/file/rotating_writer.rb +98 -0
  52. data/lib/redhound/filter/analyzer.rb +197 -0
  53. data/lib/redhound/filter/bpf/assembler.rb +121 -0
  54. data/lib/redhound/filter/bpf/disassembler.rb +58 -0
  55. data/lib/redhound/filter/bpf/validator.rb +77 -0
  56. data/lib/redhound/filter/bpf/vm.rb +109 -0
  57. data/lib/redhound/filter/codegen.rb +413 -0
  58. data/lib/redhound/filter/lexer.rb +46 -0
  59. data/lib/redhound/filter/parser.rb +206 -0
  60. data/lib/redhound/filter/program.rb +55 -0
  61. data/lib/redhound/filter.rb +30 -0
  62. data/lib/redhound/layer.rb +75 -0
  63. data/lib/redhound/output/fields.rb +20 -0
  64. data/lib/redhound/output/follow.rb +64 -0
  65. data/lib/redhound/output/hexdump.rb +29 -0
  66. data/lib/redhound/output/json.rb +32 -0
  67. data/lib/redhound/output/summary.rb +84 -0
  68. data/lib/redhound/output/timestamp.rb +34 -0
  69. data/lib/redhound/output/tree.rb +25 -0
  70. data/lib/redhound/packet.rb +77 -0
  71. data/lib/redhound/protocols/arp.rb +39 -0
  72. data/lib/redhound/protocols/checksum.rb +38 -0
  73. data/lib/redhound/protocols/data.rb +23 -0
  74. data/lib/redhound/protocols/dhcp.rb +140 -0
  75. data/lib/redhound/protocols/dns.rb +268 -0
  76. data/lib/redhound/protocols/ethernet.rb +24 -0
  77. data/lib/redhound/protocols/http.rb +185 -0
  78. data/lib/redhound/protocols/icmp.rb +205 -0
  79. data/lib/redhound/protocols/ipv4.rb +56 -0
  80. data/lib/redhound/protocols/ipv6.rb +68 -0
  81. data/lib/redhound/protocols/link.rb +70 -0
  82. data/lib/redhound/protocols/llc.rb +37 -0
  83. data/lib/redhound/protocols/ntp.rb +45 -0
  84. data/lib/redhound/protocols/tcp.rb +88 -0
  85. data/lib/redhound/protocols/tls.rb +218 -0
  86. data/lib/redhound/protocols/tunnel.rb +48 -0
  87. data/lib/redhound/protocols/udp.rb +32 -0
  88. data/lib/redhound/protocols/vlan.rb +23 -0
  89. data/lib/redhound/reader.rb +8 -0
  90. data/lib/redhound/registry.rb +64 -0
  91. data/lib/redhound/stream_dissector.rb +168 -0
  92. data/lib/redhound/util/seq.rb +21 -0
  93. data/lib/redhound/version.rb +2 -1
  94. data/lib/redhound/writer.rb +25 -43
  95. data/lib/redhound.rb +81 -9
  96. data/sig/generated/redhound/analysis/flow_key.rbs +15 -0
  97. data/sig/generated/redhound/analysis/flow_table.rbs +102 -0
  98. data/sig/generated/redhound/analysis/ip_reassembler.rbs +52 -0
  99. data/sig/generated/redhound/analysis/stats.rbs +80 -0
  100. data/sig/generated/redhound/analysis/tcp_analysis.rbs +15 -0
  101. data/sig/generated/redhound/analysis/tcp_reassembler.rbs +39 -0
  102. data/sig/generated/redhound/analysis/tcp_stream.rbs +60 -0
  103. data/sig/generated/redhound/analysis.rbs +38 -0
  104. data/sig/generated/redhound/capture/bsd/bpf_device.rbs +39 -0
  105. data/sig/generated/redhound/capture/bsd/constants.rbs +40 -0
  106. data/sig/generated/redhound/capture/bsd/ifreq.rbs +15 -0
  107. data/sig/generated/redhound/capture/file_source.rbs +67 -0
  108. data/sig/generated/redhound/capture/interface.rbs +53 -0
  109. data/sig/generated/redhound/capture/linktype.rbs +20 -0
  110. data/sig/generated/redhound/capture/linux/constants.rbs +58 -0
  111. data/sig/generated/redhound/capture/linux/cooked.rbs +15 -0
  112. data/sig/generated/redhound/capture/linux/ifreq.rbs +18 -0
  113. data/sig/generated/redhound/capture/linux/packet_socket.rbs +64 -0
  114. data/sig/generated/redhound/capture/linux/sockaddr_ll.rbs +31 -0
  115. data/sig/generated/redhound/capture/linux/tpacket_v3.rbs +35 -0
  116. data/sig/generated/redhound/capture/source.rbs +44 -0
  117. data/sig/generated/redhound/capture/stats.rbs +25 -0
  118. data/sig/generated/redhound/capture.rbs +14 -0
  119. data/sig/generated/redhound/cli/command.rbs +12 -0
  120. data/sig/generated/redhound/cli/options.rbs +25 -0
  121. data/sig/generated/redhound/cli/privileges.rbs +12 -0
  122. data/sig/generated/redhound/cli/runner.rbs +27 -0
  123. data/sig/generated/redhound/context.rbs +27 -0
  124. data/sig/generated/redhound/cursor.rbs +59 -0
  125. data/sig/generated/redhound/diagnostic.rbs +21 -0
  126. data/sig/generated/redhound/dissector.rbs +117 -0
  127. data/sig/generated/redhound/engine.rbs +23 -0
  128. data/sig/generated/redhound/errors.rbs +56 -0
  129. data/sig/generated/redhound/field.rbs +74 -0
  130. data/sig/generated/redhound/file/format.rbs +39 -0
  131. data/sig/generated/redhound/file/pcap_reader.rbs +19 -0
  132. data/sig/generated/redhound/file/pcap_writer.rbs +38 -0
  133. data/sig/generated/redhound/file/pcapng_reader.rbs +54 -0
  134. data/sig/generated/redhound/file/pcapng_writer.rbs +50 -0
  135. data/sig/generated/redhound/file/rotating_writer.rbs +42 -0
  136. data/sig/generated/redhound/filter/analyzer.rbs +42 -0
  137. data/sig/generated/redhound/filter/bpf/assembler.rbs +39 -0
  138. data/sig/generated/redhound/filter/bpf/disassembler.rbs +18 -0
  139. data/sig/generated/redhound/filter/bpf/validator.rbs +30 -0
  140. data/sig/generated/redhound/filter/bpf/vm.rbs +28 -0
  141. data/sig/generated/redhound/filter/codegen.rbs +101 -0
  142. data/sig/generated/redhound/filter/lexer.rbs +15 -0
  143. data/sig/generated/redhound/filter/parser.rbs +65 -0
  144. data/sig/generated/redhound/filter/program.rbs +48 -0
  145. data/sig/generated/redhound/filter.rbs +10 -0
  146. data/sig/generated/redhound/layer.rbs +81 -0
  147. data/sig/generated/redhound/output/fields.rbs +15 -0
  148. data/sig/generated/redhound/output/follow.rbs +21 -0
  149. data/sig/generated/redhound/output/hexdump.rbs +15 -0
  150. data/sig/generated/redhound/output/json.rbs +18 -0
  151. data/sig/generated/redhound/output/summary.rbs +21 -0
  152. data/sig/generated/redhound/output/timestamp.rbs +21 -0
  153. data/sig/generated/redhound/output/tree.rbs +15 -0
  154. data/sig/generated/redhound/packet.rbs +82 -0
  155. data/sig/generated/redhound/protocols/arp.rbs +15 -0
  156. data/sig/generated/redhound/protocols/checksum.rbs +15 -0
  157. data/sig/generated/redhound/protocols/data.rbs +17 -0
  158. data/sig/generated/redhound/protocols/dhcp.rbs +31 -0
  159. data/sig/generated/redhound/protocols/dns.rbs +42 -0
  160. data/sig/generated/redhound/protocols/ethernet.rbs +15 -0
  161. data/sig/generated/redhound/protocols/http.rbs +36 -0
  162. data/sig/generated/redhound/protocols/icmp.rbs +41 -0
  163. data/sig/generated/redhound/protocols/ipv4.rbs +15 -0
  164. data/sig/generated/redhound/protocols/ipv6.rbs +24 -0
  165. data/sig/generated/redhound/protocols/link.rbs +33 -0
  166. data/sig/generated/redhound/protocols/llc.rbs +15 -0
  167. data/sig/generated/redhound/protocols/ntp.rbs +17 -0
  168. data/sig/generated/redhound/protocols/tcp.rbs +25 -0
  169. data/sig/generated/redhound/protocols/tls.rbs +51 -0
  170. data/sig/generated/redhound/protocols/tunnel.rbs +24 -0
  171. data/sig/generated/redhound/protocols/udp.rbs +18 -0
  172. data/sig/generated/redhound/protocols/vlan.rbs +12 -0
  173. data/sig/generated/redhound/reader.rbs +7 -0
  174. data/sig/generated/redhound/registry.rbs +45 -0
  175. data/sig/generated/redhound/stream_dissector.rbs +44 -0
  176. data/sig/generated/redhound/util/seq.rbs +20 -0
  177. data/sig/generated/redhound/version.rbs +1 -0
  178. data/sig/generated/redhound/writer.rbs +6 -19
  179. data/sig/generated/redhound.rbs +18 -0
  180. data/sig/shims/io.rbs +7 -0
  181. data/sig/shims/process.rbs +3 -0
  182. metadata +177 -55
  183. data/CODE_OF_CONDUCT.md +0 -132
  184. data/Dockerfile +0 -7
  185. data/Rakefile +0 -15
  186. data/Steepfile +0 -4
  187. data/docker-compose.yml +0 -9
  188. data/lib/redhound/analyzer.rb +0 -34
  189. data/lib/redhound/builder/packet_mreq.rb +0 -56
  190. data/lib/redhound/builder/socket.rb +0 -35
  191. data/lib/redhound/builder.rb +0 -4
  192. data/lib/redhound/command.rb +0 -67
  193. data/lib/redhound/l2/ether.rb +0 -68
  194. data/lib/redhound/l2/protocol.rb +0 -33
  195. data/lib/redhound/l2.rb +0 -4
  196. data/lib/redhound/l3/arp.rb +0 -114
  197. data/lib/redhound/l3/base.rb +0 -49
  198. data/lib/redhound/l3/ipv4.rb +0 -95
  199. data/lib/redhound/l3/ipv6.rb +0 -69
  200. data/lib/redhound/l3/protocol.rb +0 -173
  201. data/lib/redhound/l3/resolver.rb +0 -30
  202. data/lib/redhound/l3.rb +0 -9
  203. data/lib/redhound/l4/base.rb +0 -31
  204. data/lib/redhound/l4/icmp.rb +0 -71
  205. data/lib/redhound/l4/resolver.rb +0 -28
  206. data/lib/redhound/l4/udp.rb +0 -63
  207. data/lib/redhound/l4.rb +0 -6
  208. data/lib/redhound/receiver.rb +0 -45
  209. data/lib/redhound/resolver.rb +0 -22
  210. data/lib/redhound/source/socket.rb +0 -18
  211. data/lib/redhound/source.rb +0 -3
  212. data/rbs_collection.lock.yaml +0 -20
  213. data/rbs_collection.yaml +0 -17
  214. data/sig/generated/redhound/analyzer.rbs +0 -14
  215. data/sig/generated/redhound/builder/packet_mreq.rbs +0 -39
  216. data/sig/generated/redhound/builder/socket.rbs +0 -24
  217. data/sig/generated/redhound/command.rbs +0 -19
  218. data/sig/generated/redhound/l2/ether.rbs +0 -41
  219. data/sig/generated/redhound/l2/protocol.rbs +0 -15
  220. data/sig/generated/redhound/l3/arp.rbs +0 -57
  221. data/sig/generated/redhound/l3/base.rbs +0 -28
  222. data/sig/generated/redhound/l3/ipv4.rbs +0 -53
  223. data/sig/generated/redhound/l3/ipv6.rbs +0 -38
  224. data/sig/generated/redhound/l3/protocol.rbs +0 -16
  225. data/sig/generated/redhound/l3/resolver.rbs +0 -16
  226. data/sig/generated/redhound/l4/base.rbs +0 -19
  227. data/sig/generated/redhound/l4/icmp.rbs +0 -33
  228. data/sig/generated/redhound/l4/resolver.rbs +0 -16
  229. data/sig/generated/redhound/l4/udp.rbs +0 -36
  230. data/sig/generated/redhound/receiver.rbs +0 -19
  231. data/sig/generated/redhound/resolver.rbs +0 -11
  232. data/sig/generated/redhound/source/socket.rbs +0 -13
@@ -0,0 +1,155 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
8
+ module Bsd
9
+ # @api private
10
+ class BpfDevice < Source
11
+ attr_reader :linktype, :interfaces
12
+
13
+ # @rbs (interface: String | Integer | Interface, ?snaplen: Integer, ?promiscuous: bool, ?buffer_size: Integer?, ?direction: Symbol, ?filter: String?) -> void
14
+ def initialize(interface:, snaplen: 262_144, promiscuous: true, buffer_size: nil, direction: :inout, filter: nil)
15
+ super()
16
+ raise ArgumentError, 'snaplen must be between 1 and 16 MiB' unless snaplen.between?(1, File::Format::MAX_RECORD)
17
+ raise ArgumentError, 'direction must be in, out or inout' unless %i[in out inout].include?(direction)
18
+
19
+ @interface = interface.is_a?(Interface) ? interface : Interface.find(interface)
20
+ raise UnsupportedPlatform, 'any capture is only available on Linux' if @interface.name == 'any'
21
+
22
+ @snaplen, @direction = snaplen, direction
23
+ @device = open_device
24
+ if buffer_size
25
+ raise ArgumentError, 'buffer size must be positive' unless buffer_size.positive?
26
+ @device.ioctl(Constants::BIOCSBLEN, [buffer_size].pack('I'))
27
+ end
28
+ @device.ioctl(Constants::BIOCSETIF, [@interface.name].pack('a16') + "\0" * 16)
29
+ buffer = "\0" * 4
30
+ @device.ioctl(Constants::BIOCGBLEN, buffer)
31
+ @buffer_size = buffer.unpack1('I')
32
+ @device.ioctl(Constants::BIOCGDLT, buffer)
33
+ dlt = buffer.unpack1('I')
34
+ @linktype = dlt == 12 ? 101 : dlt
35
+ @interfaces = [Interface.new(name: @interface.name, index: @interface.index, flags: @interface.flags,
36
+ linktype: @linktype, snaplen:)]
37
+ attach_filter(filter ? Filter.compile(filter, linktype: @linktype, snaplen:) : nil)
38
+ @device.ioctl(Constants::BIOCPROMISC, 0) if promiscuous && !@interface.loopback?
39
+ @device.ioctl(Constants::BIOCIMMEDIATE, [1].pack('I'))
40
+ configure_direction
41
+ @device.ioctl(Constants::BIOCFLUSH, 0)
42
+ @records, @offset = ''.b, 0
43
+ rescue Errno::EPERM, Errno::EACCES => error
44
+ @device&.close
45
+ raise PermissionDenied, "capture requires access to /dev/bpf*: #{error.message}"
46
+ rescue StandardError
47
+ @device&.close
48
+ raise
49
+ end
50
+
51
+ # @rbs (?timeout: Numeric?) -> Packet?
52
+ def next_packet(timeout: nil)
53
+ deadline = deadline_for(timeout)
54
+ polled = false
55
+ until @stopped || @closed
56
+ return nil if polled && deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
57
+ polled = true
58
+ if @offset >= @records.bytesize
59
+ return nil unless wait_readable(@device, deadline)
60
+ result = @device.read_nonblock(@buffer_size, exception: false)
61
+ next if result == :wait_readable
62
+ return nil unless result
63
+
64
+ @records, @offset = result, 0
65
+ next if @records.empty?
66
+ end
67
+ raise CaptureError, 'truncated BPF record header' if @offset + 18 > @records.bytesize
68
+
69
+ seconds, micros, caplen, original_length, header_length = @records.unpack('I4S', offset: @offset)
70
+ unless header_length >= 18 && caplen <= original_length && @offset + header_length + caplen <= @records.bytesize
71
+ raise CaptureError, 'invalid BPF record length'
72
+ end
73
+ direction = if @extended_header && header_length >= 20
74
+ (@records.getbyte(@offset + 19) & 1).positive? ? :out : :in
75
+ elsif @direction != :inout
76
+ @direction
77
+ end
78
+ data = @records.byteslice(@offset + header_length, [caplen, @snaplen].min)
79
+ @offset += (header_length + caplen + 3) & ~3
80
+ @number += 1
81
+ @capture_stats.captured += 1
82
+ return Packet.new(data, timestamp_ns: seconds * 1_000_000_000 + micros * 1_000,
83
+ original_length:, linktype: @linktype, interface: @interfaces.first, direction:, number: @number,
84
+ meta: { csum_not_ready: direction == :out })
85
+ end
86
+ nil
87
+ end
88
+
89
+ # @rbs (untyped program) -> void
90
+ def attach_filter(program)
91
+ instructions = program ? program.instructions : [[0x06, 0, 0, @snaplen]] #: Array[[Integer, Integer, Integer, Integer]]
92
+ Filter::BPF::Validator.validate!(instructions)
93
+ raise FilterTooLarge, 'macOS BPF programs are limited to 512 instructions' if instructions.size > 512
94
+ packed = instructions.map { |instruction| instruction.pack('S C C L') }.join
95
+ @device.ioctl(Constants::BIOCSETF, [instructions.size, packed].pack('L x4 P'))
96
+ end
97
+
98
+ # @rbs () -> Stats
99
+ def stats
100
+ unless @closed
101
+ buffer = "\0" * 8
102
+ @device.ioctl(Constants::BIOCGSTATS, buffer)
103
+ @capture_stats.received, @capture_stats.dropped = buffer.unpack('I2')
104
+ end
105
+ @capture_stats
106
+ end
107
+
108
+ # @rbs () -> void
109
+ def close
110
+ return if @closed
111
+
112
+ begin
113
+ stats
114
+ ensure
115
+ super
116
+ @device.close
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # @rbs () -> ::File
123
+ def open_device
124
+ 256.times do |index|
125
+ begin
126
+ return ::File.open("/dev/bpf#{index}", ::File::RDWR)
127
+ rescue Errno::EBUSY
128
+ next
129
+ rescue Errno::ENOENT
130
+ break
131
+ end
132
+ end
133
+ raise CaptureError, 'no available BPF device'
134
+ end
135
+
136
+ # @rbs () -> void
137
+ def configure_direction
138
+ begin
139
+ @device.ioctl(Constants::BIOCSDIRECTION, [{ in: 1, out: 2, inout: 3 }.fetch(@direction)].pack('I'))
140
+ rescue Errno::EINVAL, Errno::ENOTTY
141
+ raise UnsupportedPlatform, 'outgoing-only capture is unavailable on this macOS version' if @direction == :out
142
+
143
+ @device.ioctl(Constants::BIOCSSEESENT, [@direction == :in ? 0 : 1].pack('I'))
144
+ end
145
+ begin
146
+ @device.ioctl(Constants::BIOCSEXTHDR, [1].pack('I'))
147
+ @extended_header = true
148
+ rescue Errno::EINVAL, Errno::ENOTTY
149
+ @extended_header = false
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
8
+ module Bsd
9
+ # @api private
10
+ module Constants
11
+ # Verified against the macOS SDK's net/bpf.h on arm64 and 64-bit ioctl layout.
12
+ BIOCSBLEN = 0xc0044266
13
+ BIOCGBLEN = 0x40044266
14
+ BIOCSETF = 0x80104267
15
+ BIOCFLUSH = 0x20004268
16
+ BIOCPROMISC = 0x20004269
17
+ BIOCGDLT = 0x4004426a
18
+ BIOCSETIF = 0x8020426c
19
+ BIOCGSTATS = 0x4008426f
20
+ BIOCIMMEDIATE = 0x80044270
21
+ BIOCSSEESENT = 0x80044277
22
+ # Apple XNU bsd/net/bpf_private.h: extended headers expose direction at byte 19.
23
+ BIOCSEXTHDR = 0x8004427c
24
+ BIOCSDIRECTION = 0x8004428b
25
+ BPF_ALIGNMENT = 4
26
+ end
27
+ end
28
+ end
29
+ end
@@ -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
@@ -0,0 +1,214 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
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
+
64
+ attr_reader :reader
65
+
66
+ # @rbs (untyped input, ?filter: String?) -> void
67
+ def initialize(input, filter: nil)
68
+ super()
69
+ @owned = input.is_a?(String) && input != '-'
70
+ @io = input.is_a?(String) ? (input == '-' ? $stdin : ::File.open(input, 'rb')) : input
71
+ @io.binmode if @io.respond_to?(:binmode)
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)
84
+ @reader = if magic == File::Format::PCAPNG_MAGIC
85
+ File::PcapngReader.new(reader_io, magic)
86
+ elsif File::Format::PCAP_MAGICS.key?(magic)
87
+ File::PcapReader.new(reader_io, magic)
88
+ else
89
+ raise FileFormatError, 'unrecognized capture file magic'
90
+ end
91
+ @filter_expression = filter
92
+ @filter_program = nil # @rbs Filter::Program?
93
+ @filters = {} #: Hash[Integer, untyped]
94
+ @filters[@reader.linktype] = Filter.compile(filter, linktype: @reader.linktype) if filter
95
+ rescue StandardError, Interrupt
96
+ @io.close if @owned && @io && !@io.closed?
97
+ raise
98
+ end
99
+
100
+ # @rbs (untyped input, ?filter: String?) -> FileSource
101
+ # @rbs [T] (untyped input, ?filter: String?) { (FileSource) -> T } -> T
102
+ def self.open(input, filter: nil)
103
+ source = new(input, filter:)
104
+ return source unless block_given?
105
+
106
+ begin
107
+ yield source
108
+ ensure
109
+ source.close
110
+ end
111
+ end
112
+
113
+ # @rbs (?timeout: Numeric?) -> Packet?
114
+ def next_packet(timeout: nil)
115
+ return nil if @stopped || @closed
116
+
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
+
127
+ @capture_stats.received += 1
128
+ next if @filter_program && !@filter_program.match?(packet)
129
+ if @filter_expression
130
+ program = (@filters[packet.linktype] ||= Filter.compile(@filter_expression, linktype: packet.linktype))
131
+ next unless program.match?(packet)
132
+ end
133
+ @capture_stats.captured += 1
134
+ return packet
135
+ end
136
+ stop
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
147
+ end
148
+
149
+ # @rbs () -> Integer
150
+ def linktype = @reader.linktype
151
+
152
+ # @rbs () -> Array[Interface]
153
+ def interfaces = @reader.interfaces
154
+
155
+ # @rbs () -> Stats
156
+ def stats
157
+ @reader.respond_to?(:statistics?) && @reader.statistics? ? @reader.stats : super
158
+ end
159
+
160
+ # @rbs () -> void
161
+ def stop
162
+ super
163
+ @packets&.close
164
+ end
165
+
166
+ # @rbs () -> void
167
+ def close
168
+ return if @closed
169
+
170
+ super
171
+ @reader_thread&.join(0.2)
172
+ @io.close if @owned
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
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,70 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'socket'
5
+
6
+ module Redhound
7
+ # @api private
8
+ module Capture
9
+ # @api private
10
+ class Interface
11
+ attr_reader :name, :index, :linktype, :snaplen, :flags, :mtu, :mac, :description, :filter, :meta
12
+
13
+ # @rbs (name: String, ?index: Integer, ?linktype: Integer, ?snaplen: Integer, ?flags: Integer, ?mtu: Integer?, ?mac: String?, ?description: String?, ?filter: String?, ?meta: Hash[Symbol, untyped]) -> void
14
+ def initialize(name:, index: 0, linktype: 1, snaplen: 262_144, flags: 0, mtu: nil, mac: nil,
15
+ description: nil, filter: nil, meta: {})
16
+ @name, @index, @linktype, @snaplen, @flags = name, index, linktype, snaplen, flags
17
+ @mtu, @mac, @description, @filter, @meta = mtu, mac, description, filter, meta
18
+ end
19
+
20
+ # @rbs () -> Integer
21
+ def ifindex = @index
22
+
23
+ # @rbs () -> bool
24
+ def up? = (@flags & 1).positive?
25
+
26
+ # @rbs () -> bool
27
+ def loopback? = (@flags & 8).positive?
28
+
29
+ # @rbs () -> bool
30
+ def running? = (@flags & 0x40).positive?
31
+
32
+ # @rbs () -> String
33
+ def to_s
34
+ state = [up? ? 'Up' : 'Down', running? ? 'Running' : nil, loopback? ? 'Loopback' : nil].compact.join(', ')
35
+ "#{@index}.#{@name} [#{state}]#{@mac ? " #{@mac}" : ''}#{@mtu ? " mtu #{@mtu}" : ''}"
36
+ end
37
+
38
+ # @rbs () -> Array[Interface]
39
+ def self.all
40
+ addresses = Socket.getifaddrs
41
+ addresses.uniq(&:name).map do |address|
42
+ index = address.ifindex || 0
43
+ interface = new(name: address.name, index:, flags: address.flags)
44
+ if RUBY_PLATFORM.include?('linux')
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)
49
+ else
50
+ interface
51
+ end
52
+ end.sort_by(&:index)
53
+ end
54
+
55
+ # @rbs (String | Integer value) -> Interface
56
+ def self.find(value)
57
+ return new(name: 'any', linktype: 276) if value.to_s == 'any'
58
+
59
+ found = all.find { |item| item.name == value.to_s || item.index.to_s == value.to_s }
60
+ return found if found
61
+
62
+ names = all.map(&:name)
63
+ suggestions = names.select { |name| name.include?(value.to_s[0, 2]) }
64
+ message = "interface #{value.inspect} not found"
65
+ message += "; available: #{(suggestions.empty? ? names : suggestions).join(', ')}"
66
+ raise InterfaceNotFound, message
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,30 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
8
+ module Linktype
9
+ VALUES = { null: 0, ethernet: 1, ether: 1, raw: 101, loop: 108, sll: 113,
10
+ linux_sll: 113, radiotap: 127, ipv4: 228, ipv6: 229, sll2: 276, linux_sll2: 276 }.freeze
11
+
12
+ # @rbs (Integer | Symbol | String value) -> Integer
13
+ def self.resolve(value)
14
+ return value if value.is_a?(Integer) && value.between?(0, 65_535)
15
+
16
+ VALUES.fetch(value.to_s.downcase.to_sym) { raise ArgumentError, "unknown link type: #{value}" }
17
+ end
18
+
19
+ # @rbs (Integer value) -> String
20
+ def self.name(value)
21
+ VALUES.key(value)&.to_s || "LINKTYPE_#{value}"
22
+ end
23
+
24
+ # @rbs (Integer hardware_type) -> Integer
25
+ def self.for_hardware(hardware_type)
26
+ { 1 => 1, 772 => 1, 65_534 => 101, 803 => 127 }.fetch(hardware_type, 276)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
8
+ module Linux
9
+ # @api private
10
+ module Constants
11
+ AF_PACKET = 17
12
+ SOL_PACKET = 263
13
+ ETH_P_ALL = 3
14
+ PACKET_ADD_MEMBERSHIP = 1
15
+ PACKET_RX_RING = 5
16
+ PACKET_STATISTICS = 6
17
+ PACKET_VERSION = 10
18
+ PACKET_IGNORE_OUTGOING = 23
19
+ PACKET_MR_PROMISC = 1
20
+ PACKET_OUTGOING = 4
21
+ SO_ATTACH_FILTER = 26
22
+ SO_RCVBUFFORCE = 33
23
+ TPACKET_V3 = 2
24
+ SIOCGSTAMPNS = 0x8907
25
+ SIOCGIFHWADDR = 0x8927
26
+ SIOCGIFMTU = 0x8921
27
+ SIOCGIFFLAGS = 0x8913
28
+ TP_STATUS_USER = 0x01
29
+ TP_STATUS_CSUMNOTREADY = 0x08
30
+ TP_STATUS_VLAN_VALID = 0x10
31
+ TP_STATUS_VLAN_TPID_VALID = 0x40
32
+ TP_STATUS_CSUM_VALID = 0x80
33
+ RECEIVE_SIZE = 262_144
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
8
+ module Linux
9
+ # @api private
10
+ module Cooked
11
+ # @rbs (SockaddrLL address) -> String
12
+ def self.header(address)
13
+ [address.protocol, 0, address.index, address.hatype, address.pkttype, address.address.bytesize, address.address].pack('n n N n C C a8')
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,42 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Capture
7
+ # @api private
8
+ module Linux
9
+ # @api private
10
+ module Ifreq
11
+ # @rbs (String name, Integer command) -> String
12
+ def self.query(name, command)
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" * 24
17
+ begin
18
+ socket.ioctl(command, bytes)
19
+ bytes
20
+ ensure
21
+ socket.close
22
+ end
23
+ end
24
+
25
+ # @rbs (String name, ?index: Integer) -> Interface
26
+ def self.interface(name, index: 0)
27
+ hardware = query(name, Constants::SIOCGIFHWADDR)
28
+ hatype = hardware.unpack1('S', offset: 16) #: Integer
29
+ mac_bytes = hardware.byteslice(18, 6) #: String
30
+ mac_values = mac_bytes.unpack('C6') #: Array[Integer]
31
+ mac = mac_values.map { |value| format('%02x', value) }.join(':')
32
+ flags = query(name, Constants::SIOCGIFFLAGS).unpack1('S', offset: 16) #: Integer
33
+ mtu = query(name, Constants::SIOCGIFMTU).unpack1('i', offset: 16) #: Integer
34
+ Interface.new(name:, index:, linktype: Linktype.for_hardware(hatype), flags:, mtu:, mac:,
35
+ meta: { hardware_type: hatype })
36
+ rescue Errno::ENODEV, Errno::ENXIO
37
+ raise InterfaceNotFound, "interface #{name.inspect} not found"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end