vanken 0.1.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +28 -11
- data/data/coloring_rules.yml +67 -0
- data/docs/performance.md +53 -0
- data/docs/releases.md +2 -4
- data/docs/usage.md +51 -0
- data/exe/vanken-setup-permissions +6 -0
- data/lib/vanken/app/capture_controller.rb +66 -1
- data/lib/vanken/app/document.rb +102 -39
- data/lib/vanken/app/document_analysis.rb +75 -0
- data/lib/vanken/app/document_jobs.rb +52 -9
- data/lib/vanken/app/expert_info.rb +26 -0
- data/lib/vanken/app/io_graph.rb +47 -0
- data/lib/vanken/app/navigation.rb +77 -0
- data/lib/vanken/app/search_job.rb +62 -0
- data/lib/vanken/app/stats_job.rb +45 -0
- data/lib/vanken/capture/analyzer.rb +3 -1
- data/lib/vanken/capture/analyzer_process.rb +9 -6
- data/lib/vanken/capture/analyzer_worker.rb +2 -2
- data/lib/vanken/capture/filter_worker.rb +8 -2
- data/lib/vanken/capture/helper_main.rb +10 -3
- data/lib/vanken/capture/helper_options.rb +6 -2
- data/lib/vanken/capture/launcher.rb +1 -0
- data/lib/vanken/capture/permission_setup.rb +201 -0
- data/lib/vanken/config/analysis_settings.rb +58 -0
- data/lib/vanken/config/columns.rb +138 -0
- data/lib/vanken/config/messages.rb +105 -0
- data/lib/vanken/config/preferences.rb +34 -8
- data/lib/vanken/config/profiles.rb +74 -0
- data/lib/vanken/config/sessions.rb +69 -0
- data/lib/vanken/config/yaml_file.rb +36 -0
- data/lib/vanken/core/coloring.rb +86 -0
- data/lib/vanken/core/frame_store.rb +2 -2
- data/lib/vanken/core/stores.rb +12 -0
- data/lib/vanken/gateway/detail_builder.rb +1 -1
- data/lib/vanken/gateway/display_capture_filter.rb +102 -0
- data/lib/vanken/gateway/dissector.rb +31 -6
- data/lib/vanken/gateway/exporter.rb +125 -0
- data/lib/vanken/gateway/file_writer.rb +26 -2
- data/lib/vanken/gateway/interfaces.rb +59 -0
- data/lib/vanken/gateway/resolver.rb +131 -0
- data/lib/vanken/gateway/statistics.rb +83 -0
- data/lib/vanken/gateway/stream.rb +159 -0
- data/lib/vanken/ui/actions.rb +54 -28
- data/lib/vanken/ui/analysis_dialogs.rb +371 -0
- data/lib/vanken/ui/application.rb +183 -9
- data/lib/vanken/ui/coloring_operations.rb +104 -0
- data/lib/vanken/ui/column_operations.rb +115 -0
- data/lib/vanken/ui/dialogs.rb +41 -26
- data/lib/vanken/ui/file_operations.rb +17 -12
- data/lib/vanken/ui/filter_operations.rb +43 -15
- data/lib/vanken/ui/main_view.rb +74 -29
- data/lib/vanken/ui/navigation_operations.rb +86 -0
- data/lib/vanken/ui/packet_source.rb +33 -1
- data/lib/vanken/ui/selection.rb +8 -2
- data/lib/vanken/ui/settings_operations.rb +233 -0
- data/lib/vanken/version.rb +1 -1
- data/packaging/README.md +41 -1
- data/packaging/linux/org.vanken.capture.policy +14 -0
- data/packaging/linux/vanken-capture.wrapper +6 -0
- data/packaging/linux/vanken.desktop +9 -0
- data/packaging/linux/vanken.sudoers +2 -0
- data/packaging/macos/chmod-bpf +6 -0
- data/packaging/macos/org.vanken.chmod-bpf.plist +8 -0
- data/sig/generated/vanken/app/capture_controller.rbs +16 -0
- data/sig/generated/vanken/app/document.rbs +23 -1
- data/sig/generated/vanken/app/document_analysis.rbs +21 -0
- data/sig/generated/vanken/app/document_jobs.rbs +6 -0
- data/sig/generated/vanken/app/expert_info.rbs +30 -0
- data/sig/generated/vanken/app/io_graph.rbs +45 -0
- data/sig/generated/vanken/app/navigation.rbs +31 -0
- data/sig/generated/vanken/app/search_job.rbs +21 -0
- data/sig/generated/vanken/app/stats_job.rbs +18 -0
- data/sig/generated/vanken/capture/helper_options.rbs +6 -0
- data/sig/generated/vanken/capture/permission_setup.rbs +43 -0
- data/sig/generated/vanken/config/analysis_settings.rbs +31 -0
- data/sig/generated/vanken/config/columns.rbs +37 -0
- data/sig/generated/vanken/config/messages.rbs +14 -0
- data/sig/generated/vanken/config/preferences.rbs +1 -1
- data/sig/generated/vanken/config/profiles.rbs +29 -0
- data/sig/generated/vanken/config/sessions.rbs +17 -0
- data/sig/generated/vanken/config/yaml_file.rbs +11 -0
- data/sig/generated/vanken/core/coloring.rbs +52 -0
- data/sig/generated/vanken/core/stores.rbs +4 -0
- data/sig/generated/vanken/gateway/display_capture_filter.rbs +37 -0
- data/sig/generated/vanken/gateway/dissector.rbs +15 -2
- data/sig/generated/vanken/gateway/exporter.rbs +27 -0
- data/sig/generated/vanken/gateway/file_writer.rbs +3 -0
- data/sig/generated/vanken/gateway/interfaces.rbs +12 -0
- data/sig/generated/vanken/gateway/resolver.rbs +52 -0
- data/sig/generated/vanken/gateway/statistics.rbs +104 -0
- data/sig/generated/vanken/gateway/stream.rbs +75 -0
- data/sig/generated/vanken/ui/analysis_dialogs.rbs +71 -0
- data/sig/generated/vanken/ui/application.rbs +23 -1
- data/sig/generated/vanken/ui/coloring_operations.rbs +29 -0
- data/sig/generated/vanken/ui/column_operations.rbs +29 -0
- data/sig/generated/vanken/ui/dialogs.rbs +5 -3
- data/sig/generated/vanken/ui/file_operations.rbs +2 -0
- data/sig/generated/vanken/ui/filter_operations.rbs +3 -1
- data/sig/generated/vanken/ui/main_view.rbs +14 -2
- data/sig/generated/vanken/ui/navigation_operations.rbs +43 -0
- data/sig/generated/vanken/ui/packet_source.rbs +4 -0
- data/sig/generated/vanken/ui/settings_operations.rbs +41 -0
- metadata +61 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require_relative "dissector"
|
|
6
|
+
|
|
7
|
+
module Vanken
|
|
8
|
+
module Gateway
|
|
9
|
+
StreamChunk = Data.define(
|
|
10
|
+
:direction, #: Integer
|
|
11
|
+
:bytes, #: String
|
|
12
|
+
:missing, #: Integer
|
|
13
|
+
:number, #: Integer
|
|
14
|
+
:sequence #: Integer
|
|
15
|
+
)
|
|
16
|
+
StreamText = Data.define(
|
|
17
|
+
:direction, #: Integer
|
|
18
|
+
:text #: String
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class Stream
|
|
22
|
+
DISPLAY_LIMIT = 16 << 20 #: Integer
|
|
23
|
+
SEQUENCE_MOD = 1 << 32 #: Integer
|
|
24
|
+
attr_reader :nodes #: Array[String]
|
|
25
|
+
attr_reader :chunks #: Array[StreamChunk]
|
|
26
|
+
attr_reader :stream_id #: Integer
|
|
27
|
+
|
|
28
|
+
# @rbs (untyped document, Integer stream_id, ?cancelled: (^() -> bool)?) -> void
|
|
29
|
+
def initialize(document, stream_id, cancelled: nil)
|
|
30
|
+
raise ArgumentError, "invalid stream number" unless stream_id.is_a?(Integer) && stream_id >= 0
|
|
31
|
+
@stream_id, @nodes, @chunks = stream_id, [], []
|
|
32
|
+
pieces = [[], []] #: Array[Array[StreamChunk]]
|
|
33
|
+
starts = [nil, nil] #: Array[Integer?]
|
|
34
|
+
frontiers = [0, 0]
|
|
35
|
+
frames = document.stream_frames(stream_id)
|
|
36
|
+
frames.each do |number|
|
|
37
|
+
raise Vanken::Error, "operation cancelled" if cancelled&.call || document.closing?
|
|
38
|
+
packet = document.packet(number)
|
|
39
|
+
tcp = packet.layers.find { |layer| layer.protocol == :tcp && !layer.embedded }
|
|
40
|
+
next unless tcp
|
|
41
|
+
columns = packet.columns
|
|
42
|
+
endpoints = [endpoint(columns[:source], columns[:src_port]), endpoint(columns[:destination], columns[:dst_port])]
|
|
43
|
+
@nodes = tcp[:flags] & 0x12 == 0x12 ? endpoints.reverse : endpoints if @nodes.empty?
|
|
44
|
+
direction = endpoints == @nodes ? 0 : 1
|
|
45
|
+
sequence = document.annotations[number][:seq_rel]
|
|
46
|
+
difference = (sequence - frontiers[direction]) % SEQUENCE_MOD
|
|
47
|
+
difference -= SEQUENCE_MOD if difference >= SEQUENCE_MOD / 2
|
|
48
|
+
sequence = frontiers[direction] + difference
|
|
49
|
+
sequence += 1 if tcp[:flags] & 2 != 0
|
|
50
|
+
starts[direction] ||= sequence if tcp[:flags] & 2 != 0
|
|
51
|
+
data = packet.bytes.byteslice(tcp.payload_offset, tcp.payload_end - tcp.payload_offset)
|
|
52
|
+
frontiers[direction] = [frontiers[direction], sequence + data.bytesize].max
|
|
53
|
+
next if data.empty?
|
|
54
|
+
insert(pieces[direction], StreamChunk.new(direction: direction, bytes: data, missing: 0, number: number, sequence: sequence))
|
|
55
|
+
end
|
|
56
|
+
directions = pieces.each_with_index.map do |segments, direction|
|
|
57
|
+
cursor = starts[direction]
|
|
58
|
+
segments.flat_map do |segment|
|
|
59
|
+
missing = cursor && segment.sequence > cursor ? segment.sequence - cursor : 0
|
|
60
|
+
cursor = segment.sequence + segment.bytes.bytesize
|
|
61
|
+
gap = StreamChunk.new(direction: direction, bytes: "".b, missing: missing, number: segment.number, sequence: segment.sequence - missing)
|
|
62
|
+
missing.positive? ? [gap, segment] : [segment]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
positions = [0, 0]
|
|
66
|
+
until positions.each_with_index.all? { |index, direction| index == directions[direction].size }
|
|
67
|
+
direction = [0, 1].reject { |value| positions[value] == directions[value].size }.min_by do |value|
|
|
68
|
+
directions[value][positions[value]].number
|
|
69
|
+
end
|
|
70
|
+
raise Vanken::Error, "invalid stream direction" unless direction
|
|
71
|
+
@chunks << directions[direction][positions[direction]]
|
|
72
|
+
positions[direction] += 1
|
|
73
|
+
end
|
|
74
|
+
@nodes.freeze
|
|
75
|
+
@chunks.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @rbs (?direction: Integer?) -> Integer
|
|
79
|
+
def byte_size(direction: nil) = selected(direction).sum { |chunk| chunk.bytes.bytesize }
|
|
80
|
+
|
|
81
|
+
# @rbs (?format: Symbol, ?direction: Integer?, ?limit: Integer) -> Array[StreamText]
|
|
82
|
+
def preview(format: :ascii, direction: nil, limit: DISPLAY_LIMIT)
|
|
83
|
+
validate(format, direction)
|
|
84
|
+
raise ArgumentError, "invalid display limit" unless limit.is_a?(Integer) && limit >= 0
|
|
85
|
+
remaining = limit
|
|
86
|
+
result = [] #: Array[StreamText]
|
|
87
|
+
selected(direction).each do |chunk|
|
|
88
|
+
break if remaining.zero?
|
|
89
|
+
bytes = chunk.bytes.byteslice(0, remaining) || "".b
|
|
90
|
+
remaining -= bytes.bytesize
|
|
91
|
+
result << StreamText.new(direction: chunk.direction, text: render(chunk, bytes, format))
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @rbs (String path, ?format: Symbol, ?direction: Integer?, ?cancelled: (^() -> bool)?) -> String
|
|
97
|
+
def save(path, format: :raw, direction: nil, cancelled: nil)
|
|
98
|
+
validate(format, direction)
|
|
99
|
+
file = Tempfile.create([".vanken-stream-", ".tmp"], File.dirname(File.expand_path(path)))
|
|
100
|
+
begin
|
|
101
|
+
file.binmode
|
|
102
|
+
selected(direction).each do |chunk|
|
|
103
|
+
raise Vanken::Error, "operation cancelled" if cancelled&.call
|
|
104
|
+
file.write(render(chunk, chunk.bytes, format))
|
|
105
|
+
end
|
|
106
|
+
file.flush
|
|
107
|
+
file.fsync
|
|
108
|
+
file.close
|
|
109
|
+
File.rename(file.path, File.expand_path(path))
|
|
110
|
+
ensure
|
|
111
|
+
file.close unless file.closed?
|
|
112
|
+
FileUtils.rm_f(file.path)
|
|
113
|
+
end
|
|
114
|
+
path
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# @rbs (Integer? direction) -> Array[StreamChunk]
|
|
120
|
+
def selected(direction) = @chunks.select { |chunk| direction.nil? || chunk.direction == direction }
|
|
121
|
+
def endpoint(address, port) = address.include?(":") ? "[#{address}]:#{port}" : "#{address}:#{port}"
|
|
122
|
+
def validate(format, direction)
|
|
123
|
+
raise ArgumentError, "invalid stream format" unless %i[ascii hex raw].include?(format)
|
|
124
|
+
raise ArgumentError, "invalid stream direction" unless [nil, 0, 1].include?(direction)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def render(chunk, bytes, format)
|
|
128
|
+
return "".b if format == :raw && chunk.missing.positive?
|
|
129
|
+
return "[#{chunk.missing} bytes missing]" if chunk.missing.positive?
|
|
130
|
+
return bytes if format == :raw
|
|
131
|
+
return bytes.gsub(/[^\x20-\x7e\r\n\t]/n) { |byte| Kernel.format("\\x%02x", byte.getbyte(0)) } if format == :ascii
|
|
132
|
+
bytes.bytes.each_slice(16).with_index.map do |row, index|
|
|
133
|
+
Kernel.format("%08x %s\n", chunk.sequence + (index * 16), row.map { |byte| Kernel.format("%02x", byte) }.join(" "))
|
|
134
|
+
end.join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Keep capture arrival precedence while inserting only previously unseen ranges.
|
|
138
|
+
def insert(segments, chunk)
|
|
139
|
+
first, last = chunk.sequence, chunk.sequence + chunk.bytes.bytesize
|
|
140
|
+
index = segments.bsearch_index { |segment| segment.sequence + segment.bytes.bytesize > first } || segments.size
|
|
141
|
+
uncovered = [] #: Array[[Integer, Integer]]
|
|
142
|
+
cursor = first
|
|
143
|
+
while index < segments.size && segments[index].sequence < last
|
|
144
|
+
previous = segments[index]
|
|
145
|
+
uncovered << [cursor, [previous.sequence, last].min] if previous.sequence > cursor
|
|
146
|
+
cursor = [cursor, previous.sequence + previous.bytes.bytesize].max
|
|
147
|
+
index += 1
|
|
148
|
+
end
|
|
149
|
+
uncovered << [cursor, last] if cursor < last
|
|
150
|
+
uncovered.each do |start, finish|
|
|
151
|
+
segment = chunk.with(sequence: start, bytes: chunk.bytes.byteslice(start - first, finish - start))
|
|
152
|
+
position = segments.bsearch_index { |previous| previous.sequence > start } || segments.size
|
|
153
|
+
# ponytail: array insertion is O(n) for heavily reordered streams; use an interval tree if measured.
|
|
154
|
+
segments.insert(position, segment)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/vanken/ui/actions.rb
CHANGED
|
@@ -5,44 +5,70 @@ module Vanken
|
|
|
5
5
|
module Actions
|
|
6
6
|
def self.install(ui)
|
|
7
7
|
commands = {
|
|
8
|
-
open: ["開く", "cmd-o", -> { ui.open_dialog }], save: ["名前を付けて保存", "cmd-shift-s", -> { ui.save_dialog }],
|
|
9
|
-
close_file: ["ファイルを閉じる", "cmd-w", -> { ui.close_document }], reload: ["再読み込み", "cmd-r", -> { ui.reload_file }],
|
|
10
|
-
quit: ["終了", "cmd-q", -> { ui.quit }], preferences: ["設定", "cmd-,", -> { ui.preferences_dialog }],
|
|
11
|
-
capture: ["キャプチャ開始/停止", "cmd-e", -> { ui.capture_toggle }], restart: ["キャプチャ再開", nil, -> { ui.restart_capture }],
|
|
12
|
-
apply_filter: ["フィルタを適用", "enter", -> { ui.apply_filter }], clear_filter: ["フィルタをクリア", nil, -> { ui.clear_filter }],
|
|
13
|
-
filter_history: ["フィルタ履歴", nil, -> { ui.filter_history }], bookmark: ["フィルタを保存", nil, -> { ui.bookmark_filter }],
|
|
14
|
-
filter_selected: ["選択した項目でフィルタ", nil, -> { ui.filter_from_selection }],
|
|
15
|
-
prepare_selected: ["選択した項目のフィルタを準備", nil, -> { ui.filter_from_selection(prepare: true) }],
|
|
16
|
-
columns: ["表示する列", nil, -> { ui.columns_dialog }], autoscroll: ["末尾追従", nil, -> { ui.toggle_autoscroll }],
|
|
17
|
-
zoom_in: ["拡大", "cmd-+", -> { ui.zoom(1) }], zoom_out: ["縮小", "cmd--", -> { ui.zoom(-1) }], zoom_reset: ["標準サイズ", "cmd-0", -> { ui.zoom }],
|
|
18
|
-
previous_packet: ["前のパケット", "alt-up", -> { ui.navigate(-1) }], next_packet: ["次のパケット", "alt-down", -> { ui.navigate(1) }],
|
|
19
|
-
first_packet: ["最初のパケット", nil, -> { ui.first_packet }], last_packet: ["最後のパケット", nil, -> { ui.last_packet }],
|
|
20
|
-
go_to_packet: ["パケットへ移動", "cmd-g", -> { ui.go_to_packet }], copy_filter: ["項目をフィルタとしてコピー", nil, -> { ui.copy_text(ui.selected_node.filter) if ui.selected_node&.filter }]
|
|
8
|
+
open: [ui.t("開く"), "cmd-o", -> { ui.open_dialog }], save: [ui.t("名前を付けて保存"), "cmd-shift-s", -> { ui.save_dialog }],
|
|
9
|
+
close_file: [ui.t("ファイルを閉じる"), "cmd-w", -> { ui.close_document }], reload: [ui.t("再読み込み"), "cmd-r", -> { ui.reload_file }],
|
|
10
|
+
quit: [ui.t("終了"), "cmd-q", -> { ui.quit }], preferences: [ui.t("設定"), "cmd-,", -> { ui.preferences_dialog }],
|
|
11
|
+
capture: [ui.t("キャプチャ開始/停止"), "cmd-e", -> { ui.capture_toggle }], restart: [ui.t("キャプチャ再開"), nil, -> { ui.restart_capture }],
|
|
12
|
+
apply_filter: [ui.t("フィルタを適用"), "enter", -> { ui.apply_filter }], clear_filter: [ui.t("フィルタをクリア"), nil, -> { ui.clear_filter }],
|
|
13
|
+
filter_history: [ui.t("フィルタ履歴"), nil, -> { ui.filter_history }], bookmark: [ui.t("フィルタを保存"), nil, -> { ui.bookmark_filter }],
|
|
14
|
+
filter_selected: [ui.t("選択した項目でフィルタ"), nil, -> { ui.filter_from_selection }],
|
|
15
|
+
prepare_selected: [ui.t("選択した項目のフィルタを準備"), nil, -> { ui.filter_from_selection(prepare: true) }],
|
|
16
|
+
columns: [ui.t("表示する列"), nil, -> { ui.columns_dialog }], autoscroll: [ui.t("末尾追従"), nil, -> { ui.toggle_autoscroll }],
|
|
17
|
+
zoom_in: [ui.t("拡大"), "cmd-+", -> { ui.zoom(1) }], zoom_out: [ui.t("縮小"), "cmd--", -> { ui.zoom(-1) }], zoom_reset: [ui.t("標準サイズ"), "cmd-0", -> { ui.zoom }],
|
|
18
|
+
previous_packet: [ui.t("前のパケット"), "alt-up", -> { ui.navigate(-1) }], next_packet: [ui.t("次のパケット"), "alt-down", -> { ui.navigate(1) }],
|
|
19
|
+
first_packet: [ui.t("最初のパケット"), nil, -> { ui.first_packet }], last_packet: [ui.t("最後のパケット"), nil, -> { ui.last_packet }],
|
|
20
|
+
go_to_packet: [ui.t("パケットへ移動"), "cmd-g", -> { ui.go_to_packet }], copy_filter: [ui.t("項目をフィルタとしてコピー"), nil, -> { ui.copy_text(ui.selected_node.filter) if ui.selected_node&.filter }]
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
commands.merge!(
|
|
23
|
+
find_packet: [ui.t("パケット検索"), "cmd-f", -> { ui.find_packet }], find_next: [ui.t("次を検索"), "cmd-n", -> { ui.find_next }], find_prev: [ui.t("前を検索"), "cmd-b", -> { ui.find_prev }],
|
|
24
|
+
mark_packet: [ui.t("マーク/解除"), "cmd-m", -> { ui.mark_packet }], mark_all_displayed: [ui.t("表示中をすべてマーク"), "cmd-shift-m", -> { ui.mark_all_displayed }], unmark_all: [ui.t("すべてのマークを解除"), "cmd-alt-m", -> { ui.unmark_all }],
|
|
25
|
+
ignore_packet: [ui.t("無視/解除"), "cmd-d", -> { ui.ignore_packet }], time_reference: [ui.t("時刻基準/解除"), "cmd-t", -> { ui.time_reference }],
|
|
26
|
+
prev_in_conversation: [ui.t("同一会話の前"), "cmd-,", -> { ui.prev_in_conversation }], next_in_conversation: [ui.t("同一会話の次"), "cmd-.", -> { ui.next_in_conversation }],
|
|
27
|
+
coloring_rules: [ui.t("色付けルール"), nil, -> { ui.coloring_dialog }], toggle_coloring: [ui.t("色付け"), nil, -> { ui.toggle_coloring }],
|
|
28
|
+
follow_tcp_stream: [ui.t("TCP ストリームを追跡"), "cmd-alt-shift-t", -> { ui.follow_tcp_stream }], decode_as: [ui.t("別プロトコルとして解析"), "cmd-shift-u", -> { ui.decode_as }],
|
|
29
|
+
expert_info: [ui.t("エキスパート情報"), nil, -> { ui.expert_info }], reload_plugins: [ui.t("プラグイン再読み込み"), "cmd-shift-l", -> { ui.reload_plugins }], plugins: [ui.t("ディセクタプラグイン"), nil, -> { ui.plugins_dialog }],
|
|
30
|
+
file_properties: [ui.t("キャプチャファイルのプロパティ"), "cmd-alt-shift-c", -> { ui.file_properties }], protocol_hierarchy: [ui.t("プロトコル階層"), nil, -> { ui.protocol_hierarchy }],
|
|
31
|
+
conversations: [ui.t("会話"), nil, -> { ui.conversations }], endpoints: [ui.t("端点"), nil, -> { ui.endpoints }], io_graph: [ui.t("I/O グラフ"), nil, -> { ui.io_graph }],
|
|
32
|
+
export_packets: [ui.t("指定パケットのエクスポート"), nil, -> { ui.export_packets }], export_dissections: [ui.t("解析結果のエクスポート"), nil, -> { ui.export_dissections }],
|
|
33
|
+
command_palette: [ui.t("コマンドパレット"), "cmd-shift-k", -> { ui.command_palette }], profiles: [ui.t("プロファイル"), nil, -> { ui.profiles_dialog }])
|
|
34
|
+
commands[:preferences][1] = "cmd-shift-p"
|
|
35
|
+
commands[:restart][1] = "cmd-shift-r"
|
|
36
|
+
{"relative" => "相対時刻", "absolute" => "絶対時刻", "delta" => "前パケットとの差", "delta_displayed" => "前表示パケットとの差", "epoch" => "エポック時刻"}.each { |name, label| commands[:"time_format_#{name}"] = [ui.t("時刻: %{name}", name: ui.t(label)), nil, -> { ui.time_format(name) }] }
|
|
37
|
+
{"system" => "システム設定", "dark" => "ダーク", "light" => "ライト", "high_contrast" => "ハイコントラスト"}.each { |name, label| commands[:"theme_#{name}"] = [ui.t("テーマ: %{name}", name: ui.t(label)), nil, -> { ui.change_theme(name) }] }
|
|
38
|
+
%i[selected not and and_not or or_not].zip([ui.t("選択項目"), ui.t("選択項目を除外"), "AND", "AND NOT", "OR", "OR NOT"]).each do |mode, label|
|
|
39
|
+
commands[:"filter_#{mode}"] = [ui.t("適用: %{label}", label: label), nil, -> { ui.filter_from_selection(mode) }]
|
|
40
|
+
commands[:"prepare_#{mode}"] = [ui.t("準備: %{label}", label: label), nil, -> { ui.filter_from_selection(mode, prepare: true) }]
|
|
25
41
|
end
|
|
26
|
-
{hex: "16 進数", text: "テキスト", dump: "ダンプ", escaped: "エスケープした文字列"}.each do |format, label|
|
|
27
|
-
commands[:"copy_#{format}"] = ["バイトをコピー:
|
|
42
|
+
{hex: ui.t("16 進数"), text: ui.t("テキスト"), dump: ui.t("ダンプ"), escaped: ui.t("エスケープした文字列")}.each do |format, label|
|
|
43
|
+
commands[:"copy_#{format}"] = [ui.t("バイトをコピー: %{label}", label: label), nil, -> { ui.copy_bytes(format) }]
|
|
28
44
|
end
|
|
29
45
|
commands.each do |name, (title, keys, operation)|
|
|
30
|
-
|
|
46
|
+
needs_document = %i[find_packet find_next find_prev mark_packet mark_all_displayed unmark_all ignore_packet time_reference prev_in_conversation next_in_conversation follow_tcp_stream expert_info file_properties protocol_hierarchy conversations endpoints io_graph export_packets export_dissections]
|
|
47
|
+
enabled = ->(*) { !needs_document.include?(name) || !!ui.document }
|
|
48
|
+
checked = name == :autoscroll ? ->(*) { ui.autoscroll? } : name == :toggle_coloring ? ->(*) { ui.coloring_enabled? } : nil
|
|
49
|
+
ui.app.actions.register(name, title: title, enabled: enabled, checked: checked) { |_| operation.call }
|
|
31
50
|
next unless keys
|
|
32
|
-
key = RUBY_PLATFORM.include?("darwin") ? keys : keys.sub("cmd", "ctrl")
|
|
51
|
+
key = ui.native? && RUBY_PLATFORM.include?("darwin") ? keys : keys.sub("cmd", "ctrl")
|
|
33
52
|
ui.window.dispatcher.keymap.bind(key, name, context: name == :apply_filter ? "in_display_filter && !in_completion" : "")
|
|
34
53
|
end
|
|
35
54
|
ui.app.menu_bar = Zaniah::Menu.build do
|
|
36
|
-
submenu("ファイル") { %i[open save close_file reload quit].each { |action| item(action) } }
|
|
37
|
-
submenu("編集") { %i[copy_filter copy_hex copy_text copy_dump copy_escaped preferences].each { |action| item(action) } }
|
|
38
|
-
submenu("表示")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
submenu(ui.t("ファイル")) { %i[open save close_file reload export_packets export_dissections quit].each { |action| item(action) } }
|
|
56
|
+
submenu(ui.t("編集")) { %i[find_packet find_next find_prev mark_packet mark_all_displayed unmark_all ignore_packet time_reference copy_filter copy_hex copy_text copy_dump copy_escaped preferences profiles].each { |action| item(action) } }
|
|
57
|
+
submenu(ui.t("表示")) do
|
|
58
|
+
%i[columns autoscroll coloring_rules toggle_coloring zoom_in zoom_out zoom_reset].each { |action| item(action) }
|
|
59
|
+
submenu(ui.t("時刻形式")) { %w[relative absolute delta delta_displayed epoch].each { |name| item(:"time_format_#{name}") } }
|
|
60
|
+
submenu(ui.t("テーマ")) { %w[system dark light high_contrast].each { |name| item(:"theme_#{name}") } }
|
|
61
|
+
end
|
|
62
|
+
submenu(ui.t("移動")) { %i[previous_packet next_packet first_packet last_packet go_to_packet prev_in_conversation next_in_conversation].each { |action| item(action) } }
|
|
63
|
+
submenu(ui.t("キャプチャ")) { %i[capture restart].each { |action| item(action) } }
|
|
64
|
+
submenu(ui.t("フィルタ")) do
|
|
42
65
|
%i[apply_filter clear_filter filter_history bookmark].each { |action| item(action) }
|
|
43
|
-
submenu("選択項目を適用") { %i[selected not and and_not or or_not].each { |mode| item(:"filter_#{mode}") } }
|
|
44
|
-
submenu("選択項目から準備") { %i[selected not and and_not or or_not].each { |mode| item(:"prepare_#{mode}") } }
|
|
66
|
+
submenu(ui.t("選択項目を適用")) { %i[selected not and and_not or or_not].each { |mode| item(:"filter_#{mode}") } }
|
|
67
|
+
submenu(ui.t("選択項目から準備")) { %i[selected not and and_not or or_not].each { |mode| item(:"prepare_#{mode}") } }
|
|
45
68
|
end
|
|
69
|
+
submenu(ui.t("分析")) { %i[follow_tcp_stream decode_as expert_info plugins reload_plugins].each { |action| item(action) } }
|
|
70
|
+
submenu(ui.t("統計")) { %i[file_properties protocol_hierarchy conversations endpoints io_graph].each { |action| item(action) } }
|
|
71
|
+
submenu(ui.t("ヘルプ")) { item(:command_palette) }
|
|
46
72
|
end
|
|
47
73
|
end
|
|
48
74
|
end
|
|
@@ -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
|