vanken 0.1.0

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 (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +84 -0
  5. data/docs/filters.md +25 -0
  6. data/docs/performance.md +113 -0
  7. data/docs/releases.md +15 -0
  8. data/docs/upstream.md +32 -0
  9. data/exe/vanken +5 -0
  10. data/exe/vanken-capture +6 -0
  11. data/lib/vanken/app/capture_controller.rb +197 -0
  12. data/lib/vanken/app/document.rb +305 -0
  13. data/lib/vanken/app/document_jobs.rb +199 -0
  14. data/lib/vanken/capture/analyzer.rb +44 -0
  15. data/lib/vanken/capture/analyzer_process.rb +91 -0
  16. data/lib/vanken/capture/analyzer_wire.rb +42 -0
  17. data/lib/vanken/capture/analyzer_worker.rb +153 -0
  18. data/lib/vanken/capture/control_protocol.rb +32 -0
  19. data/lib/vanken/capture/filter_worker.rb +180 -0
  20. data/lib/vanken/capture/helper_main.rb +137 -0
  21. data/lib/vanken/capture/helper_options.rb +70 -0
  22. data/lib/vanken/capture/launcher.rb +181 -0
  23. data/lib/vanken/capture/privileges.rb +24 -0
  24. data/lib/vanken/capture/receiver.rb +39 -0
  25. data/lib/vanken/cli.rb +68 -0
  26. data/lib/vanken/config/log.rb +19 -0
  27. data/lib/vanken/config/preferences.rb +126 -0
  28. data/lib/vanken/core/display_filter/compiler.rb +108 -0
  29. data/lib/vanken/core/display_filter/errors.rb +19 -0
  30. data/lib/vanken/core/display_filter/field_resolver.rb +86 -0
  31. data/lib/vanken/core/display_filter/lexer.rb +124 -0
  32. data/lib/vanken/core/display_filter/parser.rb +157 -0
  33. data/lib/vanken/core/display_filter/types.rb +176 -0
  34. data/lib/vanken/core/frame.rb +33 -0
  35. data/lib/vanken/core/frame_store.rb +179 -0
  36. data/lib/vanken/core/frame_view.rb +68 -0
  37. data/lib/vanken/core/stores.rb +192 -0
  38. data/lib/vanken/errors.rb +9 -0
  39. data/lib/vanken/gateway/capture_filter.rb +30 -0
  40. data/lib/vanken/gateway/detail_builder.rb +63 -0
  41. data/lib/vanken/gateway/dissector.rb +176 -0
  42. data/lib/vanken/gateway/file_reader.rb +153 -0
  43. data/lib/vanken/gateway/file_writer.rb +64 -0
  44. data/lib/vanken/gateway/interfaces.rb +30 -0
  45. data/lib/vanken/gateway/live_capture.rb +79 -0
  46. data/lib/vanken/gateway/packet_snapshot.rb +89 -0
  47. data/lib/vanken/ui/actions.rb +50 -0
  48. data/lib/vanken/ui/application.rb +205 -0
  49. data/lib/vanken/ui/dialogs.rb +89 -0
  50. data/lib/vanken/ui/file_operations.rb +113 -0
  51. data/lib/vanken/ui/filter_operations.rb +120 -0
  52. data/lib/vanken/ui/main_view.rb +113 -0
  53. data/lib/vanken/ui/packet_source.rb +161 -0
  54. data/lib/vanken/ui/selection.rb +91 -0
  55. data/lib/vanken/version.rb +5 -0
  56. data/lib/vanken.rb +17 -0
  57. data/packaging/README.md +33 -0
  58. data/sig/generated/vanken/app/capture_controller.rbs +58 -0
  59. data/sig/generated/vanken/app/document.rbs +117 -0
  60. data/sig/generated/vanken/app/document_jobs.rbs +27 -0
  61. data/sig/generated/vanken/capture/analyzer.rbs +11 -0
  62. data/sig/generated/vanken/capture/analyzer_process.rbs +17 -0
  63. data/sig/generated/vanken/capture/analyzer_wire.rbs +19 -0
  64. data/sig/generated/vanken/capture/analyzer_worker.rbs +45 -0
  65. data/sig/generated/vanken/capture/control_protocol.rbs +17 -0
  66. data/sig/generated/vanken/capture/filter_worker.rbs +51 -0
  67. data/sig/generated/vanken/capture/helper_main.rbs +23 -0
  68. data/sig/generated/vanken/capture/helper_options.rbs +27 -0
  69. data/sig/generated/vanken/capture/launcher.rbs +60 -0
  70. data/sig/generated/vanken/capture/privileges.rbs +12 -0
  71. data/sig/generated/vanken/capture/receiver.rbs +15 -0
  72. data/sig/generated/vanken/cli.rbs +9 -0
  73. data/sig/generated/vanken/config/log.rbs +9 -0
  74. data/sig/generated/vanken/config/preferences.rbs +49 -0
  75. data/sig/generated/vanken/core/display_filter/compiler.rbs +45 -0
  76. data/sig/generated/vanken/core/display_filter/errors.rbs +16 -0
  77. data/sig/generated/vanken/core/display_filter/field_resolver.rbs +48 -0
  78. data/sig/generated/vanken/core/display_filter/lexer.rbs +49 -0
  79. data/sig/generated/vanken/core/display_filter/parser.rbs +95 -0
  80. data/sig/generated/vanken/core/display_filter/types.rbs +45 -0
  81. data/sig/generated/vanken/core/frame.rbs +63 -0
  82. data/sig/generated/vanken/core/frame_store.rbs +65 -0
  83. data/sig/generated/vanken/core/frame_view.rbs +36 -0
  84. data/sig/generated/vanken/core/stores.rbs +110 -0
  85. data/sig/generated/vanken/errors.rbs +18 -0
  86. data/sig/generated/vanken/gateway/capture_filter.rbs +17 -0
  87. data/sig/generated/vanken/gateway/detail_builder.rbs +21 -0
  88. data/sig/generated/vanken/gateway/dissector.rbs +113 -0
  89. data/sig/generated/vanken/gateway/file_reader.rbs +53 -0
  90. data/sig/generated/vanken/gateway/file_writer.rbs +27 -0
  91. data/sig/generated/vanken/gateway/interfaces.rbs +16 -0
  92. data/sig/generated/vanken/gateway/live_capture.rbs +48 -0
  93. data/sig/generated/vanken/gateway/packet_snapshot.rbs +48 -0
  94. data/sig/generated/vanken/ui/actions.rbs +9 -0
  95. data/sig/generated/vanken/ui/application.rbs +85 -0
  96. data/sig/generated/vanken/ui/dialogs.rbs +21 -0
  97. data/sig/generated/vanken/ui/file_operations.rbs +25 -0
  98. data/sig/generated/vanken/ui/filter_operations.rbs +33 -0
  99. data/sig/generated/vanken/ui/main_view.rbs +21 -0
  100. data/sig/generated/vanken/ui/packet_source.rbs +40 -0
  101. data/sig/generated/vanken/ui/selection.rbs +23 -0
  102. data/sig/generated/vanken/version.rbs +5 -0
  103. data/sig/vanken.rbs +25 -0
  104. metadata +192 -0
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "tempfile"
5
+ require "json"
6
+ require_relative "../core/frame_view"
7
+ require_relative "../core/display_filter/compiler"
8
+ require_relative "../capture/receiver"
9
+ require_relative "../capture/analyzer"
10
+ require_relative "../capture/analyzer_process"
11
+ require_relative "document_jobs"
12
+
13
+ module Vanken
14
+ module App
15
+ class Document
16
+ include DocumentJobs
17
+ attr_reader :store, :columns, :annotations, :error, :path, :source, :progress, :filter,
18
+ :marked, :ignored, :time_references, :catalog, :analysis_options
19
+ attr_accessor :frame_latency, :scanner
20
+
21
+ def initialize(on_update: nil, preferences: nil, store: nil, scanner: nil, process_analysis: true)
22
+ @store = store || Core::FrameStore.new
23
+ @columns = Core::ColumnStore.new
24
+ @annotations = Core::AnnotationStore.new(@store.directory)
25
+ @cache = Core::RowCache.new(limit: preferences&.get("packet_list.row_cache_rows") || 20_000)
26
+ @verify_checksums = preferences&.get("analysis.verify_checksums") || false
27
+ @dissector = Gateway::Dissector.new(verify_checksums: @verify_checksums)
28
+ @process_analysis = process_analysis
29
+ @analysis_options = {max_state_bytes: (preferences&.get("analysis.max_state_mib") || 256) << 20,
30
+ max_flows: preferences&.get("analysis.max_flows") || 100_000}.freeze
31
+ @catalog = Gateway::FieldCatalog.new
32
+ @mutex, @condition = Mutex.new, ConditionVariable.new
33
+ @jobs, @count, @generation = [], 0, 0
34
+ @display = nil
35
+ @marked, @ignored, @time_references = Set.new, Set.new, Set.new
36
+ @received, @analyzed, @cancelled, @dirty = false, false, false, false
37
+ @on_update, @last_notify = on_update, 0.0
38
+ @scanner = scanner
39
+ @scan_concurrency = preferences&.get("analysis.workers") || 4
40
+ @filter_tasks = []
41
+ @details = File.open(File.join(@store.directory, "details.jsonl"), "w+b", 0o600)
42
+ @detail_reader = File.open(File.join(@store.directory, "details.jsonl"), "rb")
43
+ @detail_index = File.open(File.join(@store.directory, "reassembled.idx"), "w+b", 0o600)
44
+ @detail_offsets = {}
45
+ @summaries = File.open(File.join(@store.directory, "summaries.bin"), "w+b", 0o600)
46
+ @summary_index = File.open(File.join(@store.directory, "summaries.idx"), "w+b", 0o600)
47
+ @summary_reader = File.open(File.join(@store.directory, "summaries.bin"), "rb")
48
+ @summary_index_reader = File.open(File.join(@store.directory, "summaries.idx"), "rb")
49
+ @summary_count = 0
50
+ @frame_latency = 0.0
51
+ end
52
+
53
+ def open(path)
54
+ @path, @source = File.expand_path(path), :file
55
+ ingest(Gateway::FileReader.new(@path))
56
+ rescue Vanken::FileError => error
57
+ fail(error)
58
+ self
59
+ end
60
+
61
+ def ingest(source, live: false)
62
+ @source ||= live ? :live : :file
63
+ @dirty = live
64
+ @reader = source
65
+ @analyzer = if @process_analysis
66
+ Capture::AnalyzerProcess.new(self, verify_checksums: @verify_checksums)
67
+ else
68
+ Capture::Analyzer.new(self, @dissector)
69
+ end
70
+ @jobs << thread { @analyzer.run }
71
+ @jobs << thread { Capture::Receiver.new(self, source).run }
72
+ self
73
+ end
74
+ def count = @mutex.synchronize { @count }
75
+ def displayed_count = @mutex.synchronize { @display ? @display.size : @count }
76
+ def number_at(index) = @mutex.synchronize { @display ? @display.fetch(index) : (index + 1).tap { |number| raise IndexError unless number.between?(1, @count) } }
77
+ def display_numbers = @mutex.synchronize { @display ? @display.dup : (1..@count).to_a }
78
+ def dirty? = @dirty
79
+ def loading? = !@analyzed && !@cancelled
80
+ def cancelled? = @cancelled
81
+ def received? = @received
82
+ def complete? = @analyzed
83
+ def closing? = !!@closing
84
+ def stream_frames(stream) = @mutex.synchronize { @annotations.streams[stream].dup }
85
+ def packet(number) = @dissector.dissect(@store.read(number))
86
+ # @rbs (Integer number) -> Gateway::PacketSnapshot?
87
+ def packet_snapshot(number)
88
+ saved = persisted_details(number)
89
+ saved && Gateway::PacketSnapshot.from_h(saved.fetch("packet"))
90
+ end
91
+ def view(number, packet: nil, snapshot: nil) = Core::FrameView.new(self, number, packet: packet, snapshot: snapshot)
92
+ def row(number)
93
+ base = @mutex.synchronize { @columns[number] }
94
+ frame = @store.metadata(number)
95
+ info = @cache.fetch(number) { persisted_summary(number) || persisted_details(number)&.fetch("info", nil) || packet(number).info }
96
+ base.merge(no: number, number: number, timestamp_ns: frame[:timestamp_ns], length: frame[:original_length], info: info)
97
+ end
98
+ def details(number)
99
+ saved = persisted_details(number)
100
+ return restore_nodes(saved.fetch("nodes")) if saved
101
+
102
+ Gateway::DetailBuilder.new.build(packet(number), annotations: @annotations[number])
103
+ end
104
+ def publish(number, packet)
105
+ committing = false
106
+ begin
107
+ columns, annotation = packet.columns, packet.annotations
108
+ @catalog.observe(packet)
109
+ saved = JSON.generate("nodes" => Gateway::DetailBuilder.new.build(packet).map(&:to_h), "info" => packet.info,
110
+ "packet" => Gateway::PacketSnapshot.from_packet(packet).to_h) + "\n" if packet.reassembled?
111
+ loop do
112
+ program, generation, snapshot = @mutex.synchronize { [@filter, @generation, @filter_context] }
113
+ matched = !program || program.match?(view(number, packet: packet, snapshot: snapshot))
114
+ committed = @mutex.synchronize do
115
+ next false if generation != @generation
116
+ committing = true
117
+ @columns.append(columns)
118
+ @annotations.append(number, annotation)
119
+ if saved
120
+ @detail_offsets[number] = @details.pos
121
+ @details.write(saved)
122
+ @details.flush
123
+ @detail_index.write([number, @detail_offsets[number]].pack("Q<Q<"))
124
+ @detail_index.flush
125
+ end
126
+ @count = number
127
+ @dirty = true if @source == :live
128
+ @display << number if @display && matched
129
+ true
130
+ end
131
+ break if committed
132
+ end
133
+ rescue StandardError => error
134
+ # Storage errors must abort, rather than appending another record.
135
+ raise if committing
136
+ return publish_failure(number, error)
137
+ end
138
+ notify
139
+ end
140
+ def publish_failure(number, error)
141
+ @mutex.synchronize do
142
+ @columns.append(source: "", destination: "", protocol: "Malformed", src_port: -1, dst_port: -1, ip_proto: -1, layers: [])
143
+ @annotations.append(number, tcp_stream: -1, seq_rel: -1, ack_rel: -1, analysis_flags: [], expert_max: 3,
144
+ expert_items: [{severity: :error, code: "vanken.internal_error", protocol: "vanken", message: error.message}], extra: {})
145
+ @count = number
146
+ @display << number if @display && !@filter
147
+ end
148
+ notify
149
+ end
150
+ # Only immutable context for this unpublished range crosses the worker pipe.
151
+ def analysis_configuration(first, last)
152
+ @mutex.synchronize do
153
+ historical = !!@filter_context
154
+ current = @filter_context || {marked: @marked, ignored: @ignored, references: @time_references,
155
+ predecessors: nil, limit: @count, last_displayed: @display ? @display.last : @count}
156
+ snapshot = current.merge(marked: current[:marked].select { |n| n.between?(first, last) }.to_set,
157
+ ignored: current[:ignored].select { |n| n.between?(first, last) }.to_set,
158
+ references: current[:references].dup, predecessors: nil,
159
+ unfiltered_predecessor: historical && current[:predecessors].nil?)
160
+ {expression: @filter&.expression || "", generation: @generation, snapshot: snapshot,
161
+ historical_context: historical, context_token: @filter_context&.object_id}
162
+ end
163
+ end
164
+ def publish_analysis_batch(batch, analyzer, generation, context_token: nil)
165
+ first, last = batch.values_at(:first, :last)
166
+ length = last - first + 1
167
+ raise Vanken::Error, "invalid analysis batch" unless length.between?(1, 256) &&
168
+ batch[:columns][:data].bytesize == length * Core::ColumnStore::SIZE &&
169
+ batch[:annotations][:data].bytesize == length * Core::AnnotationStore::SIZE && batch[:summary_index].bytesize == length * 8
170
+ loop do
171
+ committed = @mutex.synchronize do
172
+ raise Vanken::Error, "nonsequential analysis publication" unless first == @count + 1
173
+ next false if generation != @generation || context_token != @filter_context&.object_id
174
+ raise Vanken::Error, "invalid filter matches" unless batch[:matches].all? { |number| number.between?(first, last) } && batch[:matches] == batch[:matches].sort.uniq
175
+ @columns.import(batch[:columns])
176
+ @annotations.import(batch[:annotations])
177
+ batch[:details].each do |number, saved|
178
+ @detail_offsets[number] = @details.pos
179
+ @details.write(saved)
180
+ @detail_index.write([number, @detail_offsets[number]].pack("Q<Q<"))
181
+ end
182
+ base = @summaries.pos
183
+ offsets = batch[:summary_index].unpack("Q<*").map { |offset| base + offset }.pack("Q<*")
184
+ @summaries.write(batch[:summaries])
185
+ @summary_index.write(offsets)
186
+ [@annotations, @details, @detail_index, @summaries, @summary_index].each(&:flush)
187
+ @catalog.merge(batch[:fields])
188
+ @summary_count = @count = last
189
+ @dirty = true if @source == :live
190
+ @display.concat(batch[:matches]) if @display
191
+ true
192
+ end
193
+ break if committed
194
+ configuration = analysis_configuration(first, last)
195
+ batch[:matches] = analyzer.request(configuration.merge(command: :match)).fetch(:matches)
196
+ generation = configuration[:generation]
197
+ context_token = configuration[:context_token]
198
+ end
199
+ notify
200
+ end
201
+ def receiving_done = (@received = true; signal)
202
+ def analyzing_done = (@annotations.flush; @analyzed = true; notify(force: true))
203
+ def signal = @mutex.synchronize { @condition.broadcast }
204
+ def wait_for_frames = @mutex.synchronize { @condition.wait(@mutex, 0.05) }
205
+ def fail(error) = (@error = error; notify(force: true))
206
+
207
+ def time_value(number, format = :relative, references: nil)
208
+ current = @store.metadata(number)[:timestamp_ns]
209
+ base = case format.to_sym
210
+ when :epoch, :absolute then 0
211
+ when :delta then number == 1 ? current : @store.metadata(number - 1)[:timestamp_ns]
212
+ when :delta_displayed
213
+ displayed = display_numbers
214
+ index = displayed.index(number)
215
+ previous = index && index.positive? ? displayed[index - 1] : (number > count ? displayed.last : nil)
216
+ previous ? @store.metadata(previous)[:timestamp_ns] : current
217
+ else
218
+ reference = (references || @time_references).select { |value| value <= number }.max || 1
219
+ @store.metadata(reference)[:timestamp_ns]
220
+ end
221
+ (current - base) / 1e9
222
+ end
223
+
224
+ def cancel
225
+ @cancelled = true
226
+ @reader.stop if @reader.respond_to?(:stop)
227
+ signal
228
+ self
229
+ end
230
+ def wait(timeout = nil)
231
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
232
+ @jobs.each do |job|
233
+ remaining = deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
234
+ raise Vanken::Error, "background operation did not stop" if remaining && remaining <= 0 && job.alive?
235
+ raise Vanken::Error, "background operation did not stop" unless job.join(remaining && [remaining, 0].max)
236
+ end
237
+ self
238
+ end
239
+ def close
240
+ return self if @closed
241
+ @closing = true
242
+ cancel
243
+ cancel_scan
244
+ wait(3)
245
+ @annotations.close
246
+ @details.close
247
+ @detail_reader.close
248
+ @detail_index.close
249
+ @summaries.close
250
+ @summary_index.close
251
+ @summary_reader.close
252
+ @summary_index_reader.close
253
+ @store.close
254
+ @closed = true
255
+ self
256
+ end
257
+
258
+ private
259
+ def persisted_summary(number)
260
+ return nil if number > @summary_count
261
+ if @summary_index_reader.respond_to?(:pread)
262
+ offset = @summary_index_reader.pread(8, (number - 1) * 8).unpack1("Q<")
263
+ length = @summary_reader.pread(4, offset).unpack1("L<")
264
+ length.zero? ? "" : @summary_reader.pread(length, offset + 4)
265
+ else
266
+ @mutex.synchronize do
267
+ @summary_index_reader.seek((number - 1) * 8)
268
+ offset = @summary_index_reader.read(8).unpack1("Q<")
269
+ @summary_reader.seek(offset)
270
+ length = @summary_reader.read(4).unpack1("L<")
271
+ length.zero? ? "" : @summary_reader.read(length)
272
+ end
273
+ end
274
+ end
275
+ def persisted_details(number)
276
+ @mutex.synchronize do
277
+ offset = @detail_offsets[number]
278
+ next nil unless offset
279
+ @detail_reader.seek(offset)
280
+ JSON.parse(@detail_reader.gets)
281
+ end
282
+ end
283
+ def thread(&block)
284
+ Thread.new do
285
+ Thread.current.report_on_exception = false
286
+ block.call
287
+ rescue StandardError => error
288
+ fail(error)
289
+ end
290
+ end
291
+ def notify(force: false)
292
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
293
+ return unless force || now - @last_notify >= 0.1
294
+ @last_notify = now
295
+ @on_update&.call(self)
296
+ end
297
+ def restore_nodes(nodes)
298
+ nodes.map do |value|
299
+ node = value.transform_keys(&:to_sym)
300
+ Core::DetailNode.new(**node.merge(source: node[:source].to_sym, severity: node[:severity]&.to_sym, children: restore_nodes(node[:children])))
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Vanken
5
+ module App
6
+ module DocumentJobs
7
+ def apply_filter(expression)
8
+ program = Core::DisplayFilter.compile(expression, catalog: @catalog)
9
+ cancel_scan
10
+ if expression.empty?
11
+ @mutex.synchronize do
12
+ @filter = @filter_context = @display = @progress = @filter_job = nil
13
+ end
14
+ notify(force: true)
15
+ return self
16
+ end
17
+ generation, limit, snapshot = @mutex.synchronize do
18
+ context = filter_snapshot(displayed_delta: program.fields.include?("frame.time_delta_displayed"))
19
+ @filter = program
20
+ @filter_context = context
21
+ # Reserve the history's capacity before publishing matches without placeholder rows.
22
+ @display = Array.new(@count)
23
+ @display[0, @count] = []
24
+ @progress = 0.0
25
+ [@generation, @count, context]
26
+ end
27
+ @filter_job = thread do
28
+ if @scanner && !program.fast?
29
+ path = @mutex.synchronize { @annotations.snapshot(File.join(@store.directory, "annotations-#{generation}.json")) }
30
+ snapshot = snapshot.merge(annotations_json: path).freeze
31
+ end
32
+ historical_cursor = 0
33
+ ranges = (1..limit).step(10_000).map { |first| [first, [first + 10_000, limit + 1].min] }
34
+ ranges.each_slice(@scanner && !program.fast? ? @scan_concurrency : 1) do |window|
35
+ break if stale_job?(generation)
36
+ pending = window.map do |first, last|
37
+ if @scanner && !program.fast?
38
+ task = @scanner.call(filter_payload(first, last, expression: expression, snapshot: snapshot))
39
+ stale = @mutex.synchronize do
40
+ @filter_tasks << task if generation == @generation && task.respond_to?(:cancel)
41
+ generation != @generation
42
+ end
43
+ task.cancel if stale && task.respond_to?(:cancel)
44
+ [last, task]
45
+ else
46
+ [last, {"matches" => (first...last).select { |number| !stale_job?(generation) && program.match?(view(number, snapshot: snapshot)) }}]
47
+ end
48
+ end
49
+ pending.each do |last, task|
50
+ break if stale_job?(generation)
51
+ value = task.respond_to?(:await) ? task.await : task
52
+ break if stale_job?(generation)
53
+ matches = value.fetch("matches")
54
+ @mutex.synchronize do
55
+ next if generation != @generation
56
+ @display[historical_cursor, 0] = matches
57
+ historical_cursor += matches.length
58
+ @progress = (last - 1).fdiv([limit, 1].max)
59
+ end
60
+ notify
61
+ end
62
+ end
63
+ @mutex.synchronize do
64
+ next if generation != @generation
65
+ @filter_context = nil
66
+ @progress = nil
67
+ @filter_tasks.clear
68
+ end
69
+ notify(force: true) unless stale_job?(generation)
70
+ rescue StandardError => error
71
+ fail(error) unless stale_job?(generation)
72
+ ensure
73
+ tasks = @mutex.synchronize do
74
+ if generation == @generation
75
+ @progress = nil
76
+ @filter_context = nil
77
+ remaining, @filter_tasks = @filter_tasks, []
78
+ remaining
79
+ else
80
+ []
81
+ end
82
+ end
83
+ tasks.each(&:cancel)
84
+ File.unlink(path) if path && File.exist?(path)
85
+ end
86
+ @jobs << @filter_job
87
+ self
88
+ end
89
+
90
+ def cancel_scan
91
+ tasks = @mutex.synchronize do
92
+ @generation += 1
93
+ @progress = nil
94
+ @filter_context = nil
95
+ saved, @filter_tasks = @filter_tasks, []
96
+ saved
97
+ end
98
+ tasks.each(&:cancel)
99
+ self
100
+ end
101
+
102
+ def filter_payload(first, last, expression: @filter&.expression || "", snapshot: nil)
103
+ unless snapshot
104
+ displayed_delta = Core::DisplayFilter.compile(expression, catalog: @catalog).fields.include?("frame.time_delta_displayed")
105
+ snapshot = @mutex.synchronize do
106
+ @snapshot_sequence = (@snapshot_sequence || 0) + 1
107
+ filter_snapshot(displayed_delta: displayed_delta).merge(annotations_json: @annotations.snapshot(File.join(@store.directory, "annotations-#{@generation}-#{@snapshot_sequence}.json")))
108
+ end
109
+ end
110
+ predecessors = (first...last).to_h { |number| [number.to_s, displayed_predecessor(number, snapshot)] }
111
+ {"spool" => @store.directory, "expr" => expression, "from" => first, "to" => last,
112
+ "decode_as" => [], "plugins" => [], "annotations_json" => snapshot[:annotations_json],
113
+ "marked" => snapshot[:marked].select { |number| number >= first && number < last },
114
+ "ignored" => snapshot[:ignored].select { |number| number >= first && number < last },
115
+ "time_references_ns" => snapshot[:references].to_h { |number| [number.to_s, @store.metadata(number)[:timestamp_ns]] },
116
+ "time_reference_ns" => snapshot[:limit].zero? ? 0 : @store.metadata(1)[:timestamp_ns],
117
+ "displayed_predecessors" => predecessors}
118
+ end
119
+
120
+ def displayed_predecessor(number, snapshot)
121
+ return number - 1 unless snapshot[:predecessors]
122
+ return snapshot[:last_displayed] || 0 if number > snapshot[:limit]
123
+ snapshot[:predecessors].fetch(number, 0)
124
+ end
125
+
126
+ def sort(key, direction = :asc)
127
+ raise ArgumentError, "invalid sort direction" unless %i[asc desc].include?(direction)
128
+ generation, sort_generation, filter_job = @mutex.synchronize do
129
+ @sort_generation = (@sort_generation || 0) + 1
130
+ [@generation, @sort_generation, @filter_job]
131
+ end
132
+ @jobs << thread do
133
+ filter_job&.join
134
+ next if generation != @generation || sort_generation != @sort_generation || @closing
135
+ numbers = display_numbers
136
+ values = numbers.to_h do |number|
137
+ value = case key.to_sym
138
+ when :no, :number then number
139
+ when :time then @store.metadata(number)[:timestamp_ns]
140
+ when :length then @store.metadata(number)[:original_length]
141
+ else row(number).fetch(key.to_sym)
142
+ end
143
+ [number, value]
144
+ end
145
+ numbers.sort! { |a, b| comparison = values[a] <=> values[b]; comparison = -comparison if direction == :desc; comparison.zero? ? a <=> b : comparison }
146
+ @mutex.synchronize do
147
+ next if generation != @generation || sort_generation != @sort_generation || @closing
148
+ @display = numbers + (@display ? @display - numbers : ((1..@count).to_a - numbers))
149
+ end
150
+ notify(force: true)
151
+ end
152
+ self
153
+ end
154
+
155
+ def save(path, format: nil, numbers: nil)
156
+ @error = nil
157
+ format ||= File.extname(path) == ".pcap" ? :pcap : :pcapng
158
+ limit = count
159
+ @save_job = thread do
160
+ temporary = Tempfile.create([".vanken-", ".tmp"], File.dirname(File.expand_path(path)))
161
+ begin
162
+ linktype = limit.zero? ? 1 : @store.metadata(1)[:linktype]
163
+ Gateway::FileWriter.open(temporary, format: format, linktype: linktype) do |writer|
164
+ (numbers || (1..limit)).each { |number| writer << @store.read(number) }
165
+ end
166
+ temporary.flush
167
+ temporary.fsync
168
+ temporary.close
169
+ File.rename(temporary.path, File.expand_path(path))
170
+ @mutex.synchronize do
171
+ @dirty = false if !numbers && @count == limit && @store.count == limit
172
+ @path = File.expand_path(path) unless numbers
173
+ end
174
+ notify(force: true)
175
+ ensure
176
+ temporary.close unless temporary.closed?
177
+ FileUtils.rm_f(temporary.path)
178
+ end
179
+ end
180
+ @jobs << @save_job
181
+ self
182
+ end
183
+
184
+ def wait_for_save(timeout = nil)
185
+ raise Vanken::Error, "save operation did not stop" if @save_job && !@save_job.join(timeout)
186
+ self
187
+ end
188
+
189
+ private
190
+ def stale_job?(generation) = @closing || generation != @generation
191
+ def filter_snapshot(displayed_delta: true)
192
+ predecessors = @display.each_with_index.to_h { |number, index| [number, index.zero? ? 0 : @display[index - 1]] }.freeze if displayed_delta && @display
193
+ {marked: @marked.dup.freeze, ignored: @ignored.dup.freeze, references: @time_references.dup.freeze,
194
+ predecessors: predecessors,
195
+ limit: @count, last_displayed: @display ? @display.last : @count}.freeze
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Vanken
5
+ module Capture
6
+ class Analyzer
7
+ def initialize(document, dissector)
8
+ @document, @dissector = document, dissector
9
+ @analysis = Gateway::Analysis.new(registry: dissector.registry, **document.analysis_options)
10
+ end
11
+ def run
12
+ number = 1
13
+ loop do
14
+ break if @document.respond_to?(:closing?) && @document.closing?
15
+ if number > @document.store.durable_count
16
+ break if @document.received? && number > @document.store.durable_count
17
+ @document.wait_for_frames
18
+ next
19
+ end
20
+ frame = @document.store.read(number)
21
+ begin
22
+ packet = @dissector.dissect(frame)
23
+ @analysis.update(packet)
24
+ rescue StandardError => error
25
+ @document.publish_failure(number, error)
26
+ packet = nil
27
+ end
28
+ @document.publish(number, packet) if packet
29
+ number += 1
30
+ if number % 256 == 0
31
+ Thread.pass
32
+ sleep(0.002) if @document.frame_latency > 0.033
33
+ end
34
+ end
35
+ ensure
36
+ begin
37
+ @analysis.close
38
+ ensure
39
+ @document.analyzing_done
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "analyzer_wire"
5
+
6
+ module Vanken
7
+ module Capture
8
+ class AnalyzerProcess
9
+ attr_reader :pid
10
+ def initialize(document, verify_checksums: false)
11
+ @document, @verify_checksums = document, verify_checksums
12
+ end
13
+ def run
14
+ unless Gem.win_platform?
15
+ raise Vanken::Error, "packet analysis must run without root privileges" if Process.uid.zero? || Process.euid.zero?
16
+ end
17
+ child_in, @input = IO.pipe
18
+ @output, child_out = IO.pipe
19
+ [child_in, @input, @output, child_out].each(&:binmode)
20
+ arguments = [RbConfig.ruby]
21
+ arguments << "--yjit" if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
22
+ arguments << File.expand_path("analyzer_worker.rb", __dir__)
23
+ @pid = Process.spawn(*arguments, in: child_in, out: child_out, close_others: true)
24
+ child_in.close
25
+ child_out.close
26
+ request(command: :start, spool: @document.store.directory, verify_checksums: @verify_checksums, analysis_options: @document.analysis_options)
27
+ number = 1
28
+ loop do
29
+ break if @document.closing?
30
+ limit = @document.store.durable_count
31
+ if number > limit
32
+ break if @document.received? && number > @document.store.durable_count
33
+ @document.wait_for_frames
34
+ next
35
+ end
36
+ last = [number + 255, limit].min
37
+ configuration = @document.analysis_configuration(number, last)
38
+ batch = request(configuration.merge(command: :analyze, first: number, last: last, interfaces: @document.store.interfaces))
39
+ raise Vanken::Error, "analysis response range mismatch" unless batch[:first] == number && batch[:last] == last
40
+ @document.publish_analysis_batch(batch, self, configuration[:generation], context_token: configuration[:context_token])
41
+ number = last + 1
42
+ end
43
+ rescue StandardError
44
+ @document.cancel
45
+ raise unless @document.closing?
46
+ ensure
47
+ child_in&.close unless child_in&.closed?
48
+ child_out&.close unless child_out&.closed?
49
+ begin
50
+ shutdown
51
+ ensure
52
+ @document.analyzing_done
53
+ end
54
+ end
55
+ def request(value)
56
+ AnalyzerWire.write(@input, value)
57
+ response = AnalyzerWire.read(@output, cancelled: -> { @document.closing? })
58
+ raise Vanken::Error, response[:error] if response[:error]
59
+ response
60
+ end
61
+ def shutdown
62
+ begin
63
+ AnalyzerWire.write(@input, {command: :stop}) if @pid && @input && !@input.closed?
64
+ rescue IOError, SystemCallError
65
+ nil
66
+ ensure
67
+ @input&.close unless @input&.closed?
68
+ @output&.close unless @output&.closed?
69
+ end
70
+ return unless @pid
71
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
72
+ status = nil
73
+ loop do
74
+ if (result = Process.waitpid2(@pid, Process::WNOHANG))
75
+ status = result.last
76
+ break
77
+ end
78
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
79
+ Process.kill("KILL", @pid)
80
+ status = Process.waitpid2(@pid).last
81
+ break
82
+ end
83
+ sleep(0.005)
84
+ end
85
+ raise Vanken::Error, "analysis worker failed while stopping" unless status.success? || @document.closing? || $!
86
+ rescue Errno::ECHILD, Errno::ESRCH
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+
5
+ module Vanken
6
+ module Capture
7
+ # These pipes are private to the same-user worker. Marshal.load only receives
8
+ # protocol messages serialized by our child, never an external serialized object.
9
+ module AnalyzerWire
10
+ VERSION = 1
11
+ MAX_BYTES = 64 << 20
12
+ def self.write(io, value)
13
+ bytes = Marshal.dump(value.merge(version: VERSION))
14
+ raise Vanken::Error, "analysis message exceeds limit" if bytes.bytesize > MAX_BYTES
15
+ io.write([bytes.bytesize].pack("L>"))
16
+ io.write(bytes)
17
+ io.flush
18
+ end
19
+ def self.read(io, cancelled: nil)
20
+ header = exact(io, 4, cancelled)
21
+ length = header.unpack1("L>")
22
+ raise Vanken::Error, "invalid analysis message size" unless length.between?(1, MAX_BYTES)
23
+ value = Marshal.load(exact(io, length, cancelled))
24
+ raise Vanken::Error, "invalid analysis protocol" unless value.is_a?(Hash) && value[:version] == VERSION
25
+ value
26
+ end
27
+ def self.exact(io, length, cancelled)
28
+ bytes = String.new(capacity: length, encoding: Encoding::BINARY)
29
+ fragment = String.new(capacity: [length, 64 << 10].min, encoding: Encoding::BINARY)
30
+ until bytes.bytesize == length
31
+ raise Vanken::Error, "analysis stopped" if cancelled&.call
32
+ next unless io.wait_readable(0.05)
33
+ part = io.read_nonblock([length - bytes.bytesize, 64 << 10].min, fragment, exception: false)
34
+ next if part == :wait_readable
35
+ raise Vanken::Error, "analysis worker closed its pipe" unless part
36
+ bytes << part
37
+ end
38
+ bytes
39
+ end
40
+ end
41
+ end
42
+ end