vanken 0.1.0 → 0.2.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +26 -11
  4. data/data/coloring_rules.yml +67 -0
  5. data/docs/performance.md +10 -0
  6. data/docs/releases.md +2 -4
  7. data/docs/usage.md +51 -0
  8. data/lib/vanken/app/capture_controller.rb +66 -1
  9. data/lib/vanken/app/document.rb +102 -39
  10. data/lib/vanken/app/document_analysis.rb +75 -0
  11. data/lib/vanken/app/document_jobs.rb +52 -9
  12. data/lib/vanken/app/expert_info.rb +26 -0
  13. data/lib/vanken/app/io_graph.rb +47 -0
  14. data/lib/vanken/app/navigation.rb +77 -0
  15. data/lib/vanken/app/search_job.rb +62 -0
  16. data/lib/vanken/app/stats_job.rb +45 -0
  17. data/lib/vanken/capture/analyzer.rb +3 -1
  18. data/lib/vanken/capture/analyzer_process.rb +9 -6
  19. data/lib/vanken/capture/analyzer_worker.rb +2 -2
  20. data/lib/vanken/capture/filter_worker.rb +8 -2
  21. data/lib/vanken/capture/helper_main.rb +10 -3
  22. data/lib/vanken/capture/helper_options.rb +6 -2
  23. data/lib/vanken/capture/launcher.rb +1 -0
  24. data/lib/vanken/config/analysis_settings.rb +58 -0
  25. data/lib/vanken/config/columns.rb +138 -0
  26. data/lib/vanken/config/messages.rb +105 -0
  27. data/lib/vanken/config/preferences.rb +34 -8
  28. data/lib/vanken/config/profiles.rb +74 -0
  29. data/lib/vanken/config/sessions.rb +69 -0
  30. data/lib/vanken/config/yaml_file.rb +36 -0
  31. data/lib/vanken/core/coloring.rb +86 -0
  32. data/lib/vanken/core/frame_store.rb +2 -2
  33. data/lib/vanken/core/stores.rb +12 -0
  34. data/lib/vanken/gateway/detail_builder.rb +1 -1
  35. data/lib/vanken/gateway/display_capture_filter.rb +102 -0
  36. data/lib/vanken/gateway/dissector.rb +31 -6
  37. data/lib/vanken/gateway/exporter.rb +125 -0
  38. data/lib/vanken/gateway/file_writer.rb +26 -2
  39. data/lib/vanken/gateway/interfaces.rb +59 -0
  40. data/lib/vanken/gateway/resolver.rb +131 -0
  41. data/lib/vanken/gateway/statistics.rb +83 -0
  42. data/lib/vanken/gateway/stream.rb +159 -0
  43. data/lib/vanken/ui/actions.rb +54 -28
  44. data/lib/vanken/ui/analysis_dialogs.rb +371 -0
  45. data/lib/vanken/ui/application.rb +183 -9
  46. data/lib/vanken/ui/coloring_operations.rb +104 -0
  47. data/lib/vanken/ui/column_operations.rb +115 -0
  48. data/lib/vanken/ui/dialogs.rb +41 -26
  49. data/lib/vanken/ui/file_operations.rb +17 -12
  50. data/lib/vanken/ui/filter_operations.rb +43 -15
  51. data/lib/vanken/ui/main_view.rb +74 -29
  52. data/lib/vanken/ui/navigation_operations.rb +86 -0
  53. data/lib/vanken/ui/packet_source.rb +33 -1
  54. data/lib/vanken/ui/selection.rb +8 -2
  55. data/lib/vanken/ui/settings_operations.rb +233 -0
  56. data/lib/vanken/version.rb +1 -1
  57. data/sig/generated/vanken/app/capture_controller.rbs +16 -0
  58. data/sig/generated/vanken/app/document.rbs +23 -1
  59. data/sig/generated/vanken/app/document_analysis.rbs +21 -0
  60. data/sig/generated/vanken/app/document_jobs.rbs +6 -0
  61. data/sig/generated/vanken/app/expert_info.rbs +30 -0
  62. data/sig/generated/vanken/app/io_graph.rbs +45 -0
  63. data/sig/generated/vanken/app/navigation.rbs +31 -0
  64. data/sig/generated/vanken/app/search_job.rbs +21 -0
  65. data/sig/generated/vanken/app/stats_job.rbs +18 -0
  66. data/sig/generated/vanken/capture/helper_options.rbs +6 -0
  67. data/sig/generated/vanken/config/analysis_settings.rbs +31 -0
  68. data/sig/generated/vanken/config/columns.rbs +37 -0
  69. data/sig/generated/vanken/config/messages.rbs +14 -0
  70. data/sig/generated/vanken/config/preferences.rbs +1 -1
  71. data/sig/generated/vanken/config/profiles.rbs +29 -0
  72. data/sig/generated/vanken/config/sessions.rbs +17 -0
  73. data/sig/generated/vanken/config/yaml_file.rbs +11 -0
  74. data/sig/generated/vanken/core/coloring.rbs +52 -0
  75. data/sig/generated/vanken/core/stores.rbs +4 -0
  76. data/sig/generated/vanken/gateway/display_capture_filter.rbs +37 -0
  77. data/sig/generated/vanken/gateway/dissector.rbs +15 -2
  78. data/sig/generated/vanken/gateway/exporter.rbs +27 -0
  79. data/sig/generated/vanken/gateway/file_writer.rbs +3 -0
  80. data/sig/generated/vanken/gateway/interfaces.rbs +12 -0
  81. data/sig/generated/vanken/gateway/resolver.rbs +52 -0
  82. data/sig/generated/vanken/gateway/statistics.rbs +104 -0
  83. data/sig/generated/vanken/gateway/stream.rbs +75 -0
  84. data/sig/generated/vanken/ui/analysis_dialogs.rbs +71 -0
  85. data/sig/generated/vanken/ui/application.rbs +23 -1
  86. data/sig/generated/vanken/ui/coloring_operations.rbs +29 -0
  87. data/sig/generated/vanken/ui/column_operations.rbs +29 -0
  88. data/sig/generated/vanken/ui/dialogs.rbs +5 -3
  89. data/sig/generated/vanken/ui/file_operations.rbs +2 -0
  90. data/sig/generated/vanken/ui/filter_operations.rbs +3 -1
  91. data/sig/generated/vanken/ui/main_view.rbs +14 -2
  92. data/sig/generated/vanken/ui/navigation_operations.rbs +43 -0
  93. data/sig/generated/vanken/ui/packet_source.rbs +4 -0
  94. data/sig/generated/vanken/ui/settings_operations.rbs +41 -0
  95. metadata +51 -3
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../app/stats_job"
5
+ require_relative "../app/expert_info"
6
+ require_relative "../app/io_graph"
7
+ require_relative "../gateway/stream"
8
+ require_relative "../gateway/exporter"
9
+
10
+ module Vanken
11
+ module UI
12
+ class AnalysisRows
13
+ def initialize(rows) = (@rows = rows)
14
+ def count = @rows.size
15
+ def row_id(index) = index
16
+ def cell(index, key)
17
+ value = @rows.fetch(index).to_h[key]
18
+ value.is_a?(Array) ? value.join(" / ") : value
19
+ end
20
+ def sort(key, direction)
21
+ @rows.sort_by! { |row| row.to_h[key] || "" }
22
+ @rows.reverse! if direction == :desc
23
+ end
24
+ def [](index) = @rows.fetch(index)
25
+ end
26
+
27
+ module AnalysisDialogs
28
+ ANALYSIS_KINDS = %i[protocol_hierarchy conversations endpoints expert_info io_graph follow_tcp_stream file_properties].freeze
29
+
30
+ def close_analysis
31
+ @analysis_generation = (@analysis_generation || 0) + 1
32
+ @statistics_job&.cancel
33
+ @statistics_job = nil
34
+ # Await actual completion; Task#cancel alone does not join an executing worker.
35
+ @analysis_task&.await
36
+ rescue StandardError
37
+ nil
38
+ ensure
39
+ @analysis_task = @analysis_results = @analysis_refresh = nil
40
+ @follow_stream = @follow_text = nil
41
+ @analysis_running = false
42
+ end
43
+
44
+ def refresh_analysis
45
+ return unless @analysis_refresh && ANALYSIS_KINDS.include?(@dialog_kind) && !@analysis_running && !@closing
46
+ @analysis_refresh.call
47
+ end
48
+
49
+ def protocol_hierarchy = statistics_dialog(:protocol_hierarchy)
50
+ def conversations = statistics_dialog(:conversations)
51
+ def endpoints = statistics_dialog(:endpoints)
52
+
53
+ def statistics_dialog(kind, displayed_only: false)
54
+ return unless document
55
+ types = %w[eth ip ipv6 tcp udp]
56
+ specs = kind == :protocol_hierarchy ? ["phs"] : types.map { |type| "#{kind == :conversations ? 'conv' : 'endpoints'},#{type}" }
57
+ controls = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only).test_id("vk.stats.displayed")
58
+ .on_change { |value, *_| statistics_dialog(kind, displayed_only: value) }
59
+ analysis_dialog(kind, {protocol_hierarchy: t("プロトコル階層"), conversations: t("会話"), endpoints: t("端点")}.fetch(kind), controls, reopen: -> { statistics_dialog(kind, displayed_only: displayed_only) })
60
+ @statistics_job = App::StatsJob.new(document, specs: specs, displayed_only: displayed_only)
61
+ job = @statistics_job
62
+ @analysis_refresh = lambda do
63
+ analysis_background(->(*) { job.refresh }) do |tables|
64
+ if kind == :protocol_hierarchy
65
+ rows = tables.first.rows
66
+ total = rows.select { |row| row.path.size == 1 }.sum(&:packets)
67
+ nodes = rows.to_h do |row|
68
+ id = row.path.join("/")
69
+ label = t("%{protocol} %{packets} パケット %{bytes} bytes %{percent}%", protocol: row.path.last, packets: row.packets, bytes: row.bytes, percent: total.zero? ? 0 : (row.packets * 100.0 / total).round(1))
70
+ [id, Core::DetailNode.new(id: id, label: label, field: nil, offset: nil, length: 0, source: :frame, severity: nil, filter: nil, children: [])]
71
+ end
72
+ roots = []
73
+ rows.each do |row|
74
+ node = nodes.fetch(row.path.join("/"))
75
+ parent = nodes[row.path.take(row.path.size - 1).join("/")]
76
+ parent ? parent.children << node : roots << node
77
+ end
78
+ analysis_content(Zaniah::UI::TreeView.new(roots, height: 300, label: t("プロトコル階層")).test_id("vk.stats.hierarchy"))
79
+ else
80
+ tabs = tables.map { |table| [table.type.to_s.upcase, statistics_table(table)] }
81
+ analysis_content(Zaniah::UI::Tabs.new(tabs, selected: @statistics_tab || 0)
82
+ .on_change { |index, *_| @statistics_tab = index }.test_id("vk.stats.tabs"))
83
+ end
84
+ end
85
+ end
86
+ refresh_analysis
87
+ end
88
+
89
+ def expert_info(displayed_only: false)
90
+ return unless document
91
+ controls = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only)
92
+ .on_change { |value, *_| expert_info(displayed_only: value) }
93
+ analysis_dialog(:expert_info, t("エキスパート情報"), controls, reopen: -> { expert_info(displayed_only: displayed_only) })
94
+ @analysis_refresh = lambda do
95
+ analysis_background(->(doc, *) { App::ExpertInfo.rows(doc, displayed_only: displayed_only) }) do |rows|
96
+ table = analysis_table(rows, %i[severity code protocol count message]) do |row, event|
97
+ select_packet(row.numbers.first) if event&.click_count.to_i >= 2
98
+ end.test_id("vk.expert.table")
99
+ analysis_content(table)
100
+ end
101
+ end
102
+ refresh_analysis
103
+ end
104
+
105
+ def io_graph(interval: 1, expressions: [""], displayed_only: false)
106
+ return unless document
107
+ interval_control = Zaniah::UI::Select.new(App::IOGraph::INTERVALS.map { |value| [t("%{value}秒", value: value), value] }, value: interval, label: t("間隔"))
108
+ .test_id("vk.io.interval")
109
+ filters = Zaniah::UI::TextArea.new(expressions.join("\n"), rows: 2, label: t("系列の表示フィルタ(1行に1系列、空行は全件)")).test_id("vk.io.series")
110
+ displayed = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only)
111
+ redraw = lambda { io_graph(interval: interval, expressions: filters.value.split("\n", -1).uniq, displayed_only: displayed.value) }
112
+ interval_control.on_change { |value, *_| interval = value; redraw.call }
113
+ displayed.on_change { redraw.call }
114
+ controls = Zaniah::Div.new.flex_col.gap(6).child(interval_control).child(filters).child(displayed)
115
+ .child(Zaniah::UI::Button.new(t("系列を適用")).test_id("vk.io.apply").on_click { redraw.call })
116
+ analysis_dialog(:io_graph, t("I/O グラフ"), controls, reopen: redraw)
117
+ @analysis_refresh = lambda do
118
+ analysis_background(->(doc, cancelled) { App::IOGraph.build(doc, interval: interval, series: expressions, displayed_only: displayed_only, cancelled: cancelled) }) do |series|
119
+ if series.first.points.empty?
120
+ analysis_content(Zaniah::UI::Label.new(t("表示するパケットがありません。")))
121
+ next
122
+ end
123
+ values = series.each_with_index.to_h { |item, index| [item.expression.empty? ? t("全件 %{index}", index: index + 1) : item.expression, item.points.map(&:packets)] }
124
+ omitted = series.first&.omitted_intervals || 0
125
+ content = Zaniah::Div.new.flex_col.gap(4)
126
+ content.child(Zaniah::UI::Label.new(t("先頭の%{count}区間を省略しています。間隔を広げると全期間を表示できます。", count: omitted), wrap: :word)) if omitted.positive?
127
+ content.child(Zaniah::UI::LineChart.new(values, width: 660, height: 220, label: t("区間ごとのパケット数")).test_id("vk.io.chart"))
128
+ analysis_content(content)
129
+ end
130
+ end
131
+ refresh_analysis
132
+ end
133
+
134
+ def follow_tcp_stream(stream_id = nil, format: :ascii, direction: nil)
135
+ return unless document
136
+ stream_id ||= selected_number && document.annotations[selected_number][:tcp_stream]
137
+ return show_error(Vanken::Error.new(t("TCPストリームを持つパケットを選択してください。"))) unless stream_id && stream_id >= 0
138
+ number = Zaniah::UI::NumberInput.new(stream_id.to_s, min: 0, label: t("ストリーム番号")).test_id("vk.follow.number")
139
+ formats = Zaniah::UI::Select.new([["ASCII", :ascii], [t("16進ダンプ"), :hex], [t("生データ(保存用)"), :raw]], value: format, label: t("表示形式"))
140
+ directions = Zaniah::UI::Select.new([[t("両方向"), nil], [t("クライアント → サーバー"), 0], [t("サーバー → クライアント"), 1]], value: direction, label: t("方向"))
141
+ reload = lambda do
142
+ value = number.number
143
+ raise ArgumentError, t("ストリーム番号は0以上の整数で指定してください。") unless value && value >= 0 && value == value.to_i
144
+ follow_tcp_stream(value.to_i, format: format, direction: direction)
145
+ rescue ArgumentError => error
146
+ show_error(error)
147
+ end
148
+ formats.on_change { |value, *_| format = value; reload.call }
149
+ directions.on_change { |value, *_| direction = value; reload.call }
150
+ search = Zaniah::UI::TextField.new("", label: t("文字列検索")).test_id("vk.follow.search")
151
+ controls = Zaniah::Div.new.flex_col.gap(6)
152
+ .child(Zaniah::Div.new.flex_row.gap(6).child(number).child(formats).child(directions)
153
+ .child(Zaniah::UI::Button.new(t("表示")).on_click { reload.call }))
154
+ .child(Zaniah::Div.new.flex_row.gap(6).child(search)
155
+ .child(Zaniah::UI::Button.new(t("次を検索")).on_click { search_stream(search.value) })
156
+ .child(Zaniah::UI::Button.new(t("保存")).test_id("vk.follow.save").on_click { save_stream_dialog(format: format, direction: direction) })
157
+ .child(Zaniah::UI::Button.new(t("このストリームを除外")).test_id("vk.follow.exclude").on_click do
158
+ set_filter("!(tcp.stream == #{stream_id})")
159
+ apply_filter
160
+ dismiss_dialog
161
+ end))
162
+ analysis_dialog(:follow_tcp_stream, t("TCP ストリームを追跡"), controls, reopen: reload)
163
+ @analysis_refresh = lambda do
164
+ analysis_background(->(doc, cancelled) { Gateway::Stream.new(doc, stream_id, cancelled: cancelled) }) do |stream|
165
+ @follow_stream = stream
166
+ chunks = stream.preview(format: format == :raw ? :hex : format, direction: direction)
167
+ runs = chunks.map { |chunk| {text: chunk.text, color: Zaniah::Color.parse(chunk.direction.zero? ? "#ce3e52" : "#277bcc")} }
168
+ @follow_text = Zaniah::UI::RichText.new(runs).test_id("vk.follow.text")
169
+ content = Zaniah::Div.new.flex_col.gap(6).child(Zaniah::UI::Label.new(stream.nodes.join(" ↔ "), wrap: :word))
170
+ if stream.byte_size(direction: direction) > Gateway::Stream::DISPLAY_LIMIT
171
+ content.child(Zaniah::UI::Label.new(t("表示は16 MiBまでです。保存は全量を含みます。"), wrap: :word))
172
+ end
173
+ content.child(Zaniah::ScrollView.new.h(280).child(@follow_text))
174
+ analysis_content(content)
175
+ end
176
+ end
177
+ refresh_analysis
178
+ rescue ArgumentError => error
179
+ show_error(error)
180
+ end
181
+
182
+ def search_stream(value)
183
+ return if value.empty? || !@follow_text
184
+ index = @follow_text.text.index(value, @follow_text.selection.head)
185
+ index ||= @follow_text.text.index(value)
186
+ return unless index
187
+ @follow_text.selection = Zaniah::TextSelection.new(index, index + value.bytesize)
188
+ @window.request_frame
189
+ end
190
+
191
+ def save_stream_dialog(format: :raw, direction: nil)
192
+ return unless @follow_stream
193
+ stream = @follow_stream
194
+ export_path("stream-#{stream.stream_id}.#{format == :raw ? 'bin' : 'txt'}") do |path|
195
+ analysis_background(->(_doc, cancelled) { stream.save(path, format: format, direction: direction, cancelled: cancelled) }) { dismiss_dialog }
196
+ end
197
+ end
198
+
199
+ def export_packets = export_dialog(false)
200
+ def export_dissections = export_dialog(true)
201
+
202
+ def export_dialog(dissections = false, scope: :all, format: nil, range_value: "", exclude_ignored: false)
203
+ return unless document
204
+ format ||= dissections ? :json : :pcapng
205
+ range = Zaniah::UI::TextField.new(range_value, label: t("範囲(1-10,20,30-)")).test_id("vk.export.range")
206
+ ignored = Zaniah::UI::Checkbox.new(t("無視したパケットを除外"), value: exclude_ignored)
207
+ scope_control = Zaniah::UI::Select.new([[t("全件"), :all], [t("表示中"), :displayed], [t("選択"), :selected], [t("マーク済み"), :marked], [t("最初と最後のマークの間"), :between_marks], [t("範囲指定"), :range]], value: scope, label: t("対象"))
208
+ .on_change { |value, *_| scope = value }
209
+ formats = dissections ? %i[json ndjson text csv] : %i[pcapng pcap]
210
+ content = Zaniah::Div.new.flex_col.gap(8).child(scope_control).child(range).child(ignored)
211
+ .child(Zaniah::UI::Select.new(formats, value: format, label: t("形式")).on_change { |value, *_| format = value })
212
+ .child(Zaniah::UI::Button.new(t("エクスポート")).test_id("vk.export.save").on_click do
213
+ numbers = Gateway::Exporter.new(document).numbers(scope: scope, range: range.value, selected: selected_number, exclude_ignored: ignored.value)
214
+ columns = @table.columns
215
+ columns = columns.map { |column| column[:key] == :time ? column.merge(format: @preferences.get("packet_list.time_format").to_sym) : column }
216
+ export_path("packets.#{format == :text ? 'txt' : format}") do |path|
217
+ analysis_background(->(doc, cancelled) { Gateway::Exporter.new(doc).write(path, format: format, numbers: numbers, columns: columns, cancelled: cancelled) }) { dismiss_dialog }
218
+ end
219
+ rescue StandardError => error
220
+ show_error(error)
221
+ end)
222
+ analysis_dialog(:export, t("%{kind}のエクスポート", kind: t(dissections ? "解析結果" : "パケット")), content, reopen: -> { export_dialog(dissections, scope: scope, format: format, range_value: range.value, exclude_ignored: ignored.value) })
223
+ end
224
+
225
+ def file_properties
226
+ return unless document
227
+ controls = Zaniah::UI::Button.new(t("SHA-256を計算")).test_id("vk.properties.sha256").disabled(!document.path || !File.file?(document.path))
228
+ .on_click do
229
+ analysis_background(->(doc, *) { Digest::SHA256.file(doc.path).hexdigest }) do |digest|
230
+ @analysis_results.child(Zaniah::UI::Label.new("SHA-256: #{digest}", wrap: :word))
231
+ @window.request_frame
232
+ end
233
+ end
234
+ analysis_dialog(:file_properties, t("キャプチャファイルのプロパティ"), controls, reopen: -> { file_properties })
235
+ @analysis_refresh = lambda do
236
+ analysis_background(->(doc, cancelled) { capture_properties(doc, cancelled) }) do |properties|
237
+ analysis_content(Zaniah::Div.new.flex_col.gap(4).children(properties.map { |label, value| Zaniah::UI::Label.new("#{label}: #{value}", wrap: :word) }))
238
+ end
239
+ end
240
+ refresh_analysis
241
+ end
242
+
243
+ private
244
+
245
+ def analysis_dialog(kind, title, controls, reopen: nil)
246
+ dismiss_dialog
247
+ @analysis_results = Zaniah::Div.new.flex_col.gap(4).child(Zaniah::UI::Label.new(t("集計中…")))
248
+ @dialog_kind = kind
249
+ @dialog_reopen = reopen
250
+ body = Zaniah::Div.new.flex_col.gap(8).child(controls).child(@analysis_results)
251
+ @dialog = Zaniah::UI::Dialog.new(body, title: title, width: [760, @window.content_size.width - 32].min, close_label: t("閉じる"))
252
+ .on_close { dismiss_dialog }.test_id("vk.#{kind}")
253
+ @window.request_frame
254
+ end
255
+
256
+ def analysis_background(operation, &publish)
257
+ return if @analysis_running || @closing || !document
258
+ doc, generation = document, @analysis_generation || 0
259
+ @analysis_running = true
260
+ @analysis_task = @app.executor.background do
261
+ cancelled = -> { @closing || generation != (@analysis_generation || 0) || !document.equal?(doc) }
262
+ next if cancelled.call
263
+ value = operation.call(doc, cancelled)
264
+ @app.executor.post do
265
+ next if cancelled.call
266
+ @analysis_running = false
267
+ publish.call(value)
268
+ end
269
+ rescue StandardError => error
270
+ @app.executor.post do
271
+ next if cancelled.call
272
+ @analysis_running = false
273
+ show_error(error)
274
+ end
275
+ end
276
+ end
277
+
278
+ def analysis_content(content)
279
+ @analysis_results.children.clear
280
+ @analysis_results.child(content)
281
+ @window.request_frame
282
+ end
283
+
284
+ def analysis_table(rows, keys, &selection)
285
+ labels = {severity: "重大度", code: "コード", protocol: "プロトコル", count: "件数", message: "メッセージ",
286
+ addr_a: "アドレス A", port_a: "ポート A", addr_b: "アドレス B", port_b: "ポート B", packets_ab: "パケット A → B", packets_ba: "パケット B → A", bytes_ab: "バイト A → B", bytes_ba: "バイト B → A", duration_ns: "期間(ns)",
287
+ address: "アドレス", port: "ポート", packets_tx: "送信パケット", packets_rx: "受信パケット", bytes_tx: "送信バイト", bytes_rx: "受信バイト"}
288
+ label = t({expert_info: "エキスパート情報", conversations: "会話", endpoints: "端点"}.fetch(@dialog_kind, "統計"))
289
+ source = AnalysisRows.new(rows.dup)
290
+ table = Zaniah::UI::VirtualTable.new(source, columns: keys.map { |key| {key: key, label: t(labels.fetch(key, key.to_s)), width: key == :message ? 400 : 130} }, height: 300, label: label)
291
+ .on_sort { |key, direction, _| source.sort(key, direction); table.invalidate }
292
+ table.on_select { |index, event, *_| selection.call(source[index], event) } if selection
293
+ table
294
+ end
295
+
296
+ def statistics_table(statistics)
297
+ rows = statistics.rows
298
+ columns = statistics.kind == :conv ? %i[addr_a port_a addr_b port_b packets_ab packets_ba bytes_ab bytes_ba duration_ns] : %i[address port packets_tx packets_rx bytes_tx bytes_rx]
299
+ selected = nil
300
+ table = analysis_table(rows, columns) { |row, _| selected = row }
301
+ content = Zaniah::Div.new.flex_col.gap(6)
302
+ .child(Zaniah::ScrollView.new(axis: :horizontal).h(300).child(table.w(columns.size * 130)))
303
+ content.child(Zaniah::UI::Label.new(t("メモリ上限により%{count}行が省略されました。", count: statistics.evicted), wrap: :word)) if statistics.evicted.positive?
304
+ content.child(Zaniah::Div.new.flex_row.gap(6)
305
+ .child(Zaniah::UI::Button.new(t("フィルタとして適用")).on_click do
306
+ next unless selected
307
+ set_filter(statistics_filter(statistics, selected))
308
+ apply_filter
309
+ dismiss_dialog
310
+ end)
311
+ .child(Zaniah::UI::Button.new(t("TCP ストリームを追跡")).disabled(statistics.type != :tcp).on_click do
312
+ next unless selected && statistics.type == :tcp
313
+ expression = statistics_filter(statistics, selected)
314
+ doc = document
315
+ program = Core::DisplayFilter.compile(expression, catalog: doc.catalog)
316
+ analysis_background(->(*) { (1..doc.count).find { |number| program.match?(doc.view(number)) } }) do |number|
317
+ follow_tcp_stream(doc.annotations[number][:tcp_stream]) if number
318
+ end
319
+ end))
320
+ content
321
+ end
322
+
323
+ def statistics_filter(table, row)
324
+ protocol = table.type.to_s
325
+ if table.kind == :endpoints
326
+ address = %i[tcp udp].include?(table.type) ? (row.address.include?(":") ? "ipv6" : "ip") : protocol
327
+ expression = "#{address}.addr == #{row.address}"
328
+ return %i[tcp udp].include?(table.type) ? "#{expression} && #{protocol}.port == #{row.port}" : expression
329
+ end
330
+ address = %i[tcp udp].include?(table.type) ? (row.addr_a.include?(":") ? "ipv6" : "ip") : protocol
331
+ forward = "#{address}.src == #{row.addr_a} && #{address}.dst == #{row.addr_b}"
332
+ backward = "#{address}.src == #{row.addr_b} && #{address}.dst == #{row.addr_a}"
333
+ if %i[tcp udp].include?(table.type)
334
+ forward += " && #{protocol}.srcport == #{row.port_a} && #{protocol}.dstport == #{row.port_b}"
335
+ backward += " && #{protocol}.srcport == #{row.port_b} && #{protocol}.dstport == #{row.port_a}"
336
+ end
337
+ "(#{forward}) || (#{backward})"
338
+ end
339
+
340
+ def export_path(default_name, &operation)
341
+ return path_dialog(t("エクスポート先"), &operation) if @backend == :tui
342
+ path = @window.prompt_for_paths(save: true, default_name: default_name)&.first
343
+ operation.call(path) if path
344
+ rescue StandardError => error
345
+ show_error(error)
346
+ end
347
+
348
+ def capture_properties(doc, cancelled)
349
+ count, bytes, captured, first, last = doc.count, 0, 0, nil, nil
350
+ interfaces = Hash.new(0)
351
+ (1..count).each do |number|
352
+ raise Vanken::Error, "operation cancelled" if cancelled.call
353
+ metadata = doc.store.metadata(number)
354
+ bytes += metadata[:original_length]
355
+ captured += metadata[:caplen]
356
+ first = [first || metadata[:timestamp_ns], metadata[:timestamp_ns]].min
357
+ last = [last || metadata[:timestamp_ns], metadata[:timestamp_ns]].max
358
+ interfaces[metadata[:interface]&.fetch("name", nil) || t("未記録")] += 1
359
+ end
360
+ duration = first && last ? (last - first) / 1e9 : 0
361
+ values = {t("ファイル") => doc.path || t("未保存"), t("パケット") => count, t("期間(秒)") => duration,
362
+ t("合計(bytes)") => bytes, t("保存済み(bytes)") => captured,
363
+ t("平均pps") => duration.positive? ? (count / duration).round(3) : 0,
364
+ t("平均bps") => duration.positive? ? (bytes * 8 / duration).round(3) : 0}
365
+ interfaces.each { |name, packets| values[t("インタフェース %{name}", name: name)] = t("%{count} パケット", count: packets) }
366
+ doc.capture_stats.each { |key, value| values[t("取得統計 %{key}", key: key)] = value }
367
+ values
368
+ end
369
+ end
370
+ end
371
+ end
@@ -11,34 +11,52 @@ require_relative "selection"
11
11
  require_relative "dialogs"
12
12
  require_relative "actions"
13
13
  require_relative "packet_source"
14
+ require_relative "navigation_operations"
15
+ require_relative "coloring_operations"
16
+ require_relative "column_operations"
17
+ require_relative "settings_operations"
18
+ require_relative "analysis_dialogs"
14
19
  require_relative "../gateway/interfaces"
15
20
  require_relative "../gateway/capture_filter"
21
+ require_relative "../gateway/resolver"
16
22
  require_relative "../app/capture_controller"
17
23
  require_relative "../config/log"
24
+ require_relative "../config/messages"
25
+ require_relative "../config/sessions"
18
26
 
19
27
  module Vanken
20
28
  module UI
21
29
  class Application
22
30
  include FileOperations, FilterOperations, Selection, Dialogs
31
+ include NavigationOperations, ColoringOperations
32
+ include ColumnOperations
33
+ include SettingsOperations
34
+ include AnalysisDialogs
23
35
  attr_reader :app, :window, :preferences, :table, :tree, :hex, :filter_field, :capture,
24
- :dialog_kind, :packet_source, :interface_infos
36
+ :dialog_kind, :packet_source, :interface_infos, :interface_traffic
25
37
 
26
- def initialize(backend: nil, preferences: Config::Preferences.new, debug: false)
27
- @preferences = preferences
38
+ def initialize(backend: nil, preferences: nil, debug: false, session_parent: nil)
39
+ @profiles = Config::Profiles.new(directory: preferences&.directory || Config::Paths.config)
40
+ @preferences = preferences ||= @profiles.preferences
28
41
  @log = Config::Log.open(debug: debug, directory: File.join(preferences.directory, "logs"))
29
42
  @app = Zaniah::App.new
30
43
  @entity = @app.new_entity { {document: nil, number: nil, node: nil, details: [], bytes: "".b, error: nil} }
31
44
  @selection_generation, @file_generation, @filter_generation = 0, 0, 0
32
45
  @pool_mutex = Mutex.new
46
+ @resolver_mutex = Mutex.new
47
+ @resolution_changes = {}
33
48
  @autoscroll = false
34
49
  @backend = backend || (RUBY_PLATFORM.match?(/darwin/) ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux)
35
- @filter_field = Zaniah::UI::TextField.new("", placeholder: "表示フィルタ", clearable: false).test_id("vk.filter.input")
50
+ @session_parent = session_parent || Dir.tmpdir
51
+ @filter_field = Zaniah::UI::TextField.new("", placeholder: t("表示フィルタ"), clearable: false).test_id("vk.filter.input")
36
52
  @filter_field.completion(CompletionProvider.new(self)).on_change { |text, _| validate_filter(text) }
37
53
  @packet_source = PacketSource.new(self)
38
54
  @capture = App::CaptureController.new(preferences: preferences,
39
55
  on_document: ->(document) { @app.executor.post { attach_document(document) unless @closing } },
40
56
  on_update: ->(*) { @app.executor.post { changed unless @closing } })
41
57
  @window = @app.open_window(backend: @backend, width: preferences.get("layout.width"), height: preferences.get("layout.height"), title: "Vanken")
58
+ coloring_rules
59
+ column_settings
42
60
  unless @backend == :tui
43
61
  @window.text_system = Zaniah::TextSystem::Renderer.new
44
62
  @window.text_system.scale_factor = @window.scale_factor
@@ -59,6 +77,7 @@ module Vanken
59
77
  change_theme(preferences.get("appearance.theme"), persist: false)
60
78
  @main_view = MainView.new(self).test_id("vk.main")
61
79
  @window.draw { @main_view }
80
+ start_interface_monitor unless @backend == :headless
62
81
  show_error(Vanken::ConfigError.new(preferences.warning)) if preferences.warning
63
82
  @app.executor.background do
64
83
  infos = Gateway::Interfaces.list
@@ -71,9 +90,16 @@ module Vanken
71
90
  rescue StandardError => error
72
91
  @app.executor.post { @interface_error = error.message; changed }
73
92
  end
93
+ if @backend != :headless || session_parent
94
+ @app.executor.background do
95
+ sessions = Config::Sessions.candidates(parent: @session_parent)
96
+ @app.executor.post { recovery_dialog(sessions) if !@closing && !sessions.empty? && !@dialog }
97
+ end
98
+ end
74
99
  end
75
100
 
76
101
  def document = @app.read(@entity)[:document]
102
+ def t(text, **values) = Config::Messages.translate(text, language: @preferences.get("appearance.language"), **values)
77
103
  def selected_number = @app.read(@entity)[:number]
78
104
  def selected_node = @app.read(@entity)[:node]
79
105
  def detail_nodes = @app.read(@entity)[:details]
@@ -83,6 +109,19 @@ module Vanken
83
109
  end
84
110
  def changed
85
111
  return if @closing
112
+ apply_analysis_settings
113
+ if @reanalysis_in_flight && @reanalysis_task&.done? && document&.complete?
114
+ @reanalysis_in_flight = false
115
+ @packet_source.reset
116
+ end
117
+ if @reanalysis_selection && document&.complete? && !@reanalysis_in_flight
118
+ saved_doc, number, generation = @reanalysis_selection
119
+ @reanalysis_selection = nil
120
+ number = selected_number if generation != @selection_generation
121
+ select_packet(number) if document.equal?(saved_doc) && number && number <= document.count
122
+ end
123
+ begin_reanalysis if @pending_reanalysis
124
+ refresh_analysis
86
125
  @packet_source.refresh do
87
126
  next if @closing
88
127
  error = document&.error || @capture.error
@@ -96,9 +135,15 @@ module Vanken
96
135
  end
97
136
  def attach_document(value)
98
137
  return if @closing
138
+ wait_reanalysis
139
+ @pending_reanalysis = @reanalysis_selection = nil
140
+ @reanalysis_in_flight = false
141
+ close_analysis
142
+ cancel_packet_search
99
143
  value.scanner = ->(payload) { filter_pool.submit(payload) } if value
100
144
  @selection_generation += 1
101
145
  update { |state| state.merge!(document: value, number: nil, node: nil, details: [], bytes: "".b) }
146
+ configure_document_columns if value
102
147
  @packet_source.reset
103
148
  @tree&.replace([])
104
149
  @hex.bytes = "".b if @hex
@@ -108,9 +153,13 @@ module Vanken
108
153
  @table.scroll_to(0) if @table && value&.displayed_count&.positive? && !@autoscroll
109
154
  @window.title = "Vanken#{value&.path ? " — #{File.basename(value.path)}" : ""}" if @window.respond_to?(:title=)
110
155
  end
111
- def run = @app.run
156
+ def run
157
+ return @app.run unless @backend == :tui
158
+ @window.on_tick { @app.executor.drain }
159
+ @window.run
160
+ end
112
161
  def native? = !%i[headless tui].include?(@backend)
113
- def monospace_font = @window.text_system&.font
162
+ def monospace_font = @window.text_system.respond_to?(:font) ? @window.text_system.font : nil
114
163
  def install_panes(table, tree, hex) = (@table, @tree, @hex = table, tree, hex)
115
164
  def filter_pool
116
165
  @pool_mutex.synchronize do
@@ -125,6 +174,11 @@ module Vanken
125
174
  def close
126
175
  return if @closed
127
176
  @closing = true
177
+ stop_interface_monitor
178
+ reset_resolver
179
+ wait_reanalysis
180
+ close_analysis
181
+ cancel_packet_search
128
182
  @packet_source.reset
129
183
  @selection_generation += 1
130
184
  @filter_generation += 1
@@ -147,6 +201,7 @@ module Vanken
147
201
  theme = theme.with(typography: theme.typography.with(size_md: size, size_sm: size - 1, size_xs: size - 2))
148
202
  @app.global(:theme, theme)
149
203
  @preferences.set("appearance.theme", name) if persist
204
+ @packet_source.reset
150
205
  @window.request_frame
151
206
  end
152
207
  def zoom(delta = nil)
@@ -187,19 +242,138 @@ module Vanken
187
242
  end
188
243
  def status_text
189
244
  doc = document
190
- return "キャプチャファイルを開くか、インタフェースを選んで開始してください" unless doc
245
+ return t("キャプチャファイルを開くか、インタフェースを選んで開始してください") unless doc
191
246
  dropped = @capture.stats.fetch("dropped", @capture.stats.fetch(:dropped, 0))
192
247
  total = doc.store.durable_count
193
- "#{doc.loading? ? '読み込み中' : 'パケット'} #{@packet_source.packet_count} / #{total} 表示 #{@packet_source.count} ドロップ #{dropped}#{doc.progress ? " #{(doc.progress * 100).round}%" : ''}"
248
+ t("%{label} %{count} / %{total} 表示 %{displayed} ドロップ %{dropped}%{progress}",
249
+ label: t(doc.loading? ? "読み込み中" : "パケット"), count: @packet_source.packet_count, total: total,
250
+ displayed: @packet_source.count, dropped: dropped, progress: doc.progress ? " #{(doc.progress * 100).round}%" : "")
194
251
  end
195
252
  def copy_text(text) = @window.write_clipboard([Zaniah::Clipboard::Item.new({"text/plain" => text})])
196
253
  def show_error(error)
197
254
  @log.error("#{error.class}: #{error.message}")
198
255
  @last_error = error
256
+ dismiss_dialog
199
257
  @dialog_kind = :error
200
- @dialog = Zaniah::UI::Dialog.new(Zaniah::UI::Label.new(error.message, wrap: :word), title: "操作を完了できません").test_id("vk.error")
258
+ @dialog_reopen = -> { show_error(error) }
259
+ @dialog = Zaniah::UI::Dialog.new(Zaniah::UI::Label.new(error.message, wrap: :word), title: t("操作を完了できません"), close_label: t("閉じる")).test_id("vk.error")
260
+ .on_close { dismiss_dialog }
201
261
  @window.request_frame
202
262
  end
263
+
264
+ def start_interface_monitor
265
+ return if @traffic_thread
266
+ @traffic_mutex, @traffic_condition = Mutex.new, ConditionVariable.new
267
+ @traffic_thread = Thread.new do
268
+ previous, history = nil, {}
269
+ loop do
270
+ break if @closing
271
+ sample = Gateway::Interfaces.sample_traffic(previous: previous, history: history)
272
+ previous, history = sample.values_at(:previous, :history)
273
+ post = @traffic_mutex.synchronize do
274
+ @traffic_sample = sample
275
+ !@traffic_posted && (@traffic_posted = true)
276
+ end
277
+ if post
278
+ @app.executor.post do
279
+ value = @traffic_mutex.synchronize { @traffic_posted = false; @traffic_sample }
280
+ next if @closing
281
+ @interface_traffic = value
282
+ @app.update(@entity) { |_state, cx| cx.notify }
283
+ end
284
+ end
285
+ @traffic_mutex.synchronize { @traffic_condition.wait(@traffic_mutex, 1) unless @closing }
286
+ end
287
+ end
288
+ end
289
+
290
+ def stop_interface_monitor
291
+ return unless @traffic_thread
292
+ @traffic_mutex.synchronize { @traffic_condition.broadcast }
293
+ @traffic_thread.join
294
+ @traffic_thread = nil
295
+ end
296
+
297
+ def resolve_address(address, document:, number:)
298
+ resolver = @resolver_mutex.synchronize do
299
+ next if @closing || !@preferences.get("name_resolution.enabled")
300
+ @resolver ||= Gateway::Resolver.new(timeout: @preferences.get("name_resolution.timeout"), limit: @preferences.get("name_resolution.cache_size"))
301
+ end
302
+ return address unless resolver
303
+ resolver.request(address) do |_name|
304
+ post = @resolver_mutex.synchronize do
305
+ next false if @closing || !resolver.equal?(@resolver)
306
+ (@resolution_changes[document] ||= Set.new).add(number)
307
+ !@resolution_posted && (@resolution_posted = true)
308
+ end
309
+ next unless post
310
+ @app.executor.post do
311
+ changes = @resolver_mutex.synchronize do
312
+ saved, @resolution_changes = @resolution_changes, {}
313
+ @resolution_posted = false
314
+ saved
315
+ end
316
+ next if @closing
317
+ numbers = changes[self.document]
318
+ @packet_source.invalidate_rows(numbers) if numbers && !numbers.empty?
319
+ end
320
+ end
321
+ end
322
+
323
+ def reset_resolver
324
+ old = @resolver_mutex.synchronize do
325
+ previous, @resolver = @resolver, nil
326
+ @resolution_changes = {}
327
+ @resolution_posted = false
328
+ previous
329
+ end
330
+ return unless old
331
+ @closing ? old.close : @app.executor.background { old.close }
332
+ end
333
+
334
+ def recovery_dialog(sessions)
335
+ content = Zaniah::Div.new.flex_col.gap(8)
336
+ sessions.each do |directory|
337
+ content.child(Zaniah::Div.new.flex_col.gap(4)
338
+ .child(Zaniah::UI::Label.new(directory, wrap: :word))
339
+ .child(Zaniah::Div.new.flex_row.gap(8)
340
+ .child(Zaniah::UI::Button.new(t("復旧")).on_click { recover_session(directory) })
341
+ .child(Zaniah::UI::Button.new(t("破棄"), variant: :danger).on_click do
342
+ Config::Sessions.discard(directory, parent: @session_parent)
343
+ remaining = sessions.reject { |value| value == directory }
344
+ remaining.empty? ? dismiss_dialog : recovery_dialog(remaining)
345
+ rescue StandardError => error
346
+ show_error(error)
347
+ end)))
348
+ end
349
+ show_dialog(:recovery, t("前回のキャプチャを復旧"), Zaniah::ScrollView.new.h(240).child(content), reopen: -> { recovery_dialog(sessions) })
350
+ end
351
+
352
+ def recover_session(directory)
353
+ request_destructive do
354
+ previous = document
355
+ attach_document(nil)
356
+ dismiss_dialog
357
+ @file_generation += 1
358
+ generation = @file_generation
359
+ preferences = @preferences
360
+ @app.executor.background do
361
+ previous&.close
362
+ doc = Config::Sessions.recover(directory, parent: @session_parent, preferences: preferences,
363
+ on_update: ->(updated) { @app.executor.post { changed if !@closing && document.equal?(updated) } })
364
+ @app.executor.post do
365
+ if !@closing && generation == @file_generation
366
+ attach_document(doc)
367
+ changed
368
+ else
369
+ doc.close
370
+ end
371
+ end
372
+ rescue StandardError => error
373
+ @app.executor.post { show_error(error) if !@closing && generation == @file_generation }
374
+ end
375
+ end
376
+ end
203
377
  end
204
378
  end
205
379
  end