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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "yaml_file"
4
+
5
+ module Vanken
6
+ module Config
7
+ class AnalysisSettings
8
+ def initialize(directory: Paths.config) = (@directory = directory)
9
+
10
+ def decode_as
11
+ value = YamlFile.read(File.join(@directory, "decode_as.yml"), default: {"rules" => []}).fetch("rules", [])
12
+ validate_rules(value)
13
+ value
14
+ end
15
+
16
+ def decode_as=(rules)
17
+ validate_rules(rules)
18
+ YamlFile.write(File.join(@directory, "decode_as.yml"), "rules" => rules)
19
+ end
20
+
21
+ def plugin_entries
22
+ entries = YamlFile.read(File.join(@directory, "plugins.yml"), default: {"files" => []}).fetch("files", [])
23
+ raise ConfigError, "invalid plugin list" unless entries.is_a?(Array) && entries.all? do |entry|
24
+ entry.is_a?(Hash) && entry["path"].is_a?(String) && !entry["path"].include?("\0") &&
25
+ [true, false].include?(entry["trusted"]) && [true, false].include?(entry["enabled"])
26
+ end
27
+ entries
28
+ end
29
+ def plugins = plugin_entries.select { |entry| entry["enabled"] && entry["trusted"] }.map { |entry| entry["path"] }
30
+ def untrusted_plugins = plugin_entries.select { |entry| entry["enabled"] && !entry["trusted"] }.map { |entry| entry["path"] }
31
+
32
+ def register_plugin(path)
33
+ raise ConfigError, "invalid plugin path" unless path.is_a?(String) && !path.include?("\0") && File.file?(path)
34
+ absolute = File.expand_path(path)
35
+ entries = plugin_entries.reject { |entry| entry["path"] == absolute }
36
+ entries << {"path" => absolute, "enabled" => true, "trusted" => false}
37
+ save_plugins(entries)
38
+ end
39
+
40
+ def remove_plugin(path) = save_plugins(plugin_entries.reject { |entry| entry["path"] == File.expand_path(path) })
41
+
42
+ def trust_plugins(paths)
43
+ entries = plugin_entries
44
+ raise ConfigError, "plugin is not registered" unless paths.all? { |path| entries.any? { |entry| entry["path"] == path } }
45
+ entries.each { |entry| entry["trusted"] = true if paths.include?(entry["path"]) }
46
+ save_plugins(entries)
47
+ end
48
+
49
+ private
50
+
51
+ def save_plugins(entries) = YamlFile.write(File.join(@directory, "plugins.yml"), "files" => entries)
52
+ def validate_rules(rules)
53
+ valid = rules.is_a?(Array) && rules.all? { |rule| rule.is_a?(String) && rule.match?(/\A[a-z][a-z0-9_.]*==[^,\s]+,[a-z][a-z0-9_]*\z/i) }
54
+ raise ConfigError, "invalid Decode As rules" unless valid
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "yaml_file"
5
+
6
+ module Vanken
7
+ module Config
8
+ class Columns
9
+ BUILTINS = [[:no, "No.", 64], [:time, "Time", 128], [:source, "Source", 164],
10
+ [:destination, "Destination", 164], [:protocol, "Protocol", 90], [:length, "Length", 76], [:info, "Info", 560]].freeze
11
+
12
+ def self.load(directory:, legacy: [])
13
+ path = File.join(directory, "columns.yml")
14
+ raise ConfigError, "columns configuration must not be a symlink" if File.symlink?(path)
15
+ return new(YamlFile.read(path)) if File.file?(path)
16
+ settings = new
17
+ unless legacy.empty?
18
+ ordered = settings.to_h["columns"].sort_by { |column| legacy.index { |item| item["key"] == column["key"] } || legacy.size }
19
+ ordered.each do |column|
20
+ saved = legacy.find { |item| item["key"] == column["key"] }
21
+ column.merge!(saved.slice("width", "visible")) if saved
22
+ end
23
+ settings.replace("schema_version" => 1, "columns" => ordered)
24
+ end
25
+ settings
26
+ end
27
+
28
+ def initialize(payload = nil)
29
+ payload ||= {"schema_version" => 1, "columns" => BUILTINS.map do |key, label, width|
30
+ {"key" => key.to_s, "label" => label, "width" => width, "visible" => true, "field" => nil}
31
+ end}
32
+ replace(payload)
33
+ end
34
+
35
+ def replace(payload)
36
+ raise ConfigError, "unsupported columns schema" unless payload.is_a?(Hash) && payload["schema_version"] == 1
37
+ items = payload["columns"]
38
+ raise ConfigError, "columns must contain between 1 and 64 entries" unless items.is_a?(Array) && items.size.between?(1, 64)
39
+ rows = items.map do |item|
40
+ raise ConfigError, "invalid column" unless item.is_a?(Hash)
41
+ key, label, width, visible, field = item.values_at("key", "label", "width", "visible", "field")
42
+ custom = self.class.valid_field?(field) && key == "field:#{field}"
43
+ builtin = field.nil? && BUILTINS.any? { |entry| entry.first.to_s == key }
44
+ raise ConfigError, "invalid column key or field" unless key.is_a?(String) && (custom || builtin)
45
+ raise ConfigError, "invalid column label" unless label.is_a?(String) && !label.empty? && label.bytesize <= 256 && !label.include?("\0")
46
+ raise ConfigError, "invalid column width or visibility" unless width.is_a?(Numeric) && width.finite? && width.between?(40, 4096) && [true, false].include?(visible)
47
+ {"key" => key.dup.freeze, "label" => label.dup.freeze, "width" => width, "visible" => visible, "field" => field&.dup&.freeze}.freeze
48
+ end
49
+ raise ConfigError, "duplicate columns" unless rows.map { |item| item["key"] }.uniq.size == rows.size
50
+ @columns = rows.freeze
51
+ self
52
+ end
53
+
54
+ def self.valid_field?(field)
55
+ field.is_a?(String) && field.bytesize <= 256 && /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\z/.match?(field)
56
+ end
57
+
58
+ def to_h = {"schema_version" => 1, "columns" => @columns.map(&:dup)}
59
+ def custom = @columns.select { |column| column["field"] }
60
+ def table_columns
61
+ @columns.map do |column|
62
+ key = column.fetch("key").to_sym
63
+ {key: key, label: column.fetch("label"), width: column.fetch("width"), visible: column.fetch("visible"),
64
+ align: %i[no time length].include?(key) ? :end : :start}
65
+ end
66
+ end
67
+
68
+ def add(field, label: field, width: 120)
69
+ raise ConfigError, "invalid custom field name" unless self.class.valid_field?(field)
70
+ key = "field:#{field}"
71
+ rows = to_h["columns"]
72
+ existing = rows.find { |item| item["key"] == key }
73
+ if existing
74
+ existing["visible"] = true
75
+ else
76
+ rows << {"key" => key, "label" => label, "width" => width, "visible" => true, "field" => field}
77
+ end
78
+ replace("schema_version" => 1, "columns" => rows)
79
+ key
80
+ end
81
+
82
+ def remove(key)
83
+ raise ConfigError, "hide builtin columns instead of removing them" unless custom.any? { |item| item["key"] == key.to_s }
84
+ replace("schema_version" => 1, "columns" => to_h["columns"].reject { |item| item["key"] == key.to_s })
85
+ end
86
+
87
+ def move(key, index)
88
+ rows = to_h["columns"]
89
+ raise ConfigError, "column position is outside the list" unless index.is_a?(Integer) && index.between?(0, rows.size - 1)
90
+ item = rows.find { |column| column["key"] == key.to_s } || raise(ConfigError, "unknown column")
91
+ rows.delete(item)
92
+ rows.insert(index, item)
93
+ replace("schema_version" => 1, "columns" => rows)
94
+ end
95
+
96
+ def update_layout(items)
97
+ rows = items.map do |item|
98
+ key = item.fetch(:key).to_s
99
+ saved = @columns.find { |column| column["key"] == key } || raise(ConfigError, "unknown column")
100
+ saved.merge("width" => item.fetch(:width), "visible" => item.fetch(:visible))
101
+ end
102
+ raise ConfigError, "column layout is incomplete" unless rows.size == @columns.size
103
+ replace("schema_version" => 1, "columns" => rows)
104
+ rescue KeyError, NoMethodError
105
+ raise ConfigError, "invalid column layout"
106
+ end
107
+
108
+ def save(directory)
109
+ path = File.join(directory, "columns.yml")
110
+ raise ConfigError, "columns configuration must not be a symlink" if File.symlink?(directory) || File.symlink?(path)
111
+ YamlFile.write(path, to_h)
112
+ end
113
+
114
+ def self.display(values, type: nil)
115
+ # ponytail: cap a cell at 16 values and 4096 bytes; the details and byte panes retain full data.
116
+ text = values.first(16).map do |value|
117
+ text = type == :bytes && value.is_a?(String) ? value.byteslice(0, 1024).unpack1("H*") : value.to_s.byteslice(0, 1024)
118
+ escaped = text.b.gsub(/[^\x20-\x7e]/n) { |byte| format("\\x%02x", byte.getbyte(0)) }
119
+ escaped += "…" if value.to_s.bytesize > 1024
120
+ escaped
121
+ end.join(", ")
122
+ text = text.byteslice(0, 4096).force_encoding(Encoding::UTF_8).scrub + "…" if text.bytesize > 4096 || values.size > 16
123
+ text
124
+ end
125
+
126
+ def self.sort_value(value, type: nil)
127
+ return [2, 0] if value.nil?
128
+ return [0, value] if value.is_a?(Numeric)
129
+ return [0, value ? 1 : 0] if [true, false].include?(value)
130
+ return [0, IPAddr.new(value.to_s).ipv4? ? 4 : 6, IPAddr.new(value.to_s).to_i] if type == :address
131
+ return [0, value.to_s.delete(".:-").to_i(16)] if type == :mac
132
+ [1, value.to_s.b]
133
+ rescue ArgumentError
134
+ [1, value.to_s.b]
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Vanken
5
+ module Config
6
+ module Messages
7
+ EN = {
8
+ "TCP ストリームを追跡" => "Follow TCP Stream", "別プロトコルとして解析" => "Decode As",
9
+ "注記" => "note", "警告" => "warning", "エラー" => "error", "トラフィック" => "traffic",
10
+ "開く" => "Open", "名前を付けて保存" => "Save as", "ファイルを閉じる" => "Close file", "再読み込み" => "Reload",
11
+ "終了" => "Quit", "設定" => "Preferences", "キャプチャ開始/停止" => "Start or stop capture", "キャプチャ再開" => "Restart capture",
12
+ "フィルタを適用" => "Apply filter", "フィルタをクリア" => "Clear filter", "フィルタ履歴" => "Filter history", "フィルタを保存" => "Save filter",
13
+ "選択した項目でフィルタ" => "Filter by selected field", "選択した項目のフィルタを準備" => "Prepare filter from selected field",
14
+ "表示する列" => "Visible columns", "末尾追従" => "Follow tail", "末尾追従 ✓" => "Follow tail ✓",
15
+ "拡大" => "Zoom in", "縮小" => "Zoom out", "標準サイズ" => "Reset zoom",
16
+ "前のパケット" => "Previous packet", "次のパケット" => "Next packet", "最初のパケット" => "First packet", "最後のパケット" => "Last packet",
17
+ "パケットへ移動" => "Go to packet", "項目をフィルタとしてコピー" => "Copy field as filter",
18
+ "パケット検索" => "Find packet", "次を検索" => "Find next", "前を検索" => "Find previous",
19
+ "マーク/解除" => "Mark or unmark packet", "表示中をすべてマーク" => "Mark all displayed packets", "すべてのマークを解除" => "Unmark all packets",
20
+ "無視/解除" => "Ignore or unignore packet", "時刻基準/解除" => "Set or unset time reference", "同一会話の前" => "Previous in conversation", "同一会話の次" => "Next in conversation",
21
+ "色付けルール" => "Coloring rules", "色付け" => "Packet coloring", "エキスパート情報" => "Expert information",
22
+ "プラグイン再読み込み" => "Reload plugins", "ディセクタプラグイン" => "Dissector plugins", "キャプチャファイルのプロパティ" => "Capture file properties",
23
+ "プロトコル階層" => "Protocol hierarchy", "会話" => "Conversations", "端点" => "Endpoints", "I/O グラフ" => "I/O graph",
24
+ "指定パケットのエクスポート" => "Export specified packets", "解析結果のエクスポート" => "Export dissections", "コマンドパレット" => "Command palette", "プロファイル" => "Profiles",
25
+ "時刻: %{name}" => "Time: %{name}", "テーマ: %{name}" => "Theme: %{name}", "選択項目" => "Selected field", "選択項目を除外" => "Exclude selected field",
26
+ "適用: %{label}" => "Apply: %{label}", "準備: %{label}" => "Prepare: %{label}", "16 進数" => "Hexadecimal", "テキスト" => "Text", "ダンプ" => "Hex dump",
27
+ "エスケープした文字列" => "Escaped text", "バイトをコピー: %{label}" => "Copy bytes: %{label}",
28
+ "ファイル" => "File", "編集" => "Edit", "表示" => "View", "時刻形式" => "Time format", "テーマ" => "Theme", "移動" => "Go",
29
+ "キャプチャ" => "Capture", "フィルタ" => "Filter", "選択項目を適用" => "Apply selected field", "選択項目から準備" => "Prepare from selected field",
30
+ "分析" => "Analyze", "統計" => "Statistics", "ヘルプ" => "Help", "表示中のパケットのみ" => "Displayed packets only",
31
+ "%{value}秒" => "%{value} s", "間隔" => "Interval", "系列の表示フィルタ(1行に1系列、空行は全件)" => "Series display filters (one per line; blank means all packets)",
32
+ "系列を適用" => "Apply series", "表示するパケットがありません。" => "There are no packets to display.", "全件 %{index}" => "All packets %{index}",
33
+ "先頭の%{count}区間を省略しています。間隔を広げると全期間を表示できます。" => "The first %{count} intervals are omitted. Select a wider interval to show the entire capture.",
34
+ "TCPストリームを持つパケットを選択してください。" => "Select a packet with a TCP stream.", "ストリーム番号" => "Stream number",
35
+ "16進ダンプ" => "Hex dump", "生データ(保存用)" => "Raw data (for saving)", "表示形式" => "Display format", "両方向" => "Both directions",
36
+ "クライアント → サーバー" => "Client → server", "サーバー → クライアント" => "Server → client", "方向" => "Direction",
37
+ "ストリーム番号は0以上の整数で指定してください。" => "The stream number must be a nonnegative integer.", "文字列検索" => "Find text",
38
+ "保存" => "Save", "このストリームを除外" => "Exclude this stream", "表示は16 MiBまでです。保存は全量を含みます。" => "The display is limited to 16 MiB. Saving includes the entire stream.",
39
+ "範囲(1-10,20,30-)" => "Range (1-10,20,30-)", "無視したパケットを除外" => "Exclude ignored packets", "全件" => "All packets", "表示中" => "Displayed",
40
+ "選択" => "Selected", "マーク済み" => "Marked", "最初と最後のマークの間" => "Between first and last marks", "範囲指定" => "Packet range", "対象" => "Scope",
41
+ "形式" => "Format", "エクスポート" => "Export", "解析結果" => "Dissections", "パケット" => "Packets", "%{kind}のエクスポート" => "Export %{kind}",
42
+ "SHA-256を計算" => "Calculate SHA-256", "集計中…" => "Calculating…", "メモリ上限により%{count}行が省略されました。" => "%{count} rows were omitted because of the memory limit.",
43
+ "フィルタとして適用" => "Apply as filter", "エクスポート先" => "Export destination", "未記録" => "Not recorded", "未保存" => "Unsaved",
44
+ "期間(秒)" => "Duration (seconds)", "合計(bytes)" => "Total bytes", "保存済み(bytes)" => "Captured bytes", "平均pps" => "Average packets/s", "平均bps" => "Average bits/s",
45
+ "インタフェース %{name}" => "Interface %{name}", "取得統計 %{key}" => "Capture statistics %{key}",
46
+ "検索内容" => "Search query", "表示フィルタ" => "Display filter", "16 進" => "Hex", "文字列" => "String", "正規表現" => "Regular expression",
47
+ "パケット一覧" => "Packet list", "パケット詳細" => "Packet details", "パケットバイト" => "Packet bytes", "検索対象" => "Search in", "キャンセル" => "Cancel",
48
+ "一致するパケットはありません。" => "No matching packets were found.", "ルール" => "Rule", "名前" => "Name", "有効" => "Enabled",
49
+ "ライト" => "Light", "ダーク" => "Dark", "文字" => "Text color", "背景" => "Background", "削除" => "Delete", "追加" => "Add", "新しいルール" => "New rule",
50
+ "%{theme} %{part}" => "%{theme} %{part}", "インポート" => "Import", "適用" => "Apply", "色付けルールの読み込み先" => "Import coloring rules", "色付けルールの保存先" => "Export coloring rules",
51
+ "インタフェース" => "Interface", "キャプチャフィルタ (BPF)" => "Capture filter (BPF)", "バッファサイズ (bytes)" => "Buffer size (bytes)",
52
+ "停止件数 (0: 無制限)" => "Stop after packet count (0: unlimited)", "停止までの秒数 (0: 無制限)" => "Stop after seconds (0: unlimited)", "停止サイズ (bytes、0: 無制限)" => "Stop after bytes (0: unlimited)",
53
+ "リング保存先 (空欄: 保存しない)" => "Ring file path (blank: disabled)", "リングファイル上限 (bytes)" => "Ring file size limit (bytes)", "リング切替間隔 (秒)" => "Ring rotation interval (seconds)", "リングファイル数" => "Ring file count",
54
+ "プロミスキャスモード" => "Promiscuous mode", "送受信" => "Inbound and outbound", "受信" => "Inbound", "送信" => "Outbound",
55
+ "インタフェースを取得中です。" => "Loading interfaces…", "GUI は通常ユーザーで動作し、取得ヘルパーに必要な権限を渡します。" => "The application runs as your user and grants the capture helper the required permissions.",
56
+ "開始" => "Start", "停止" => "Stop", "再開" => "Restart", "キャプチャオプション" => "Capture options", "時刻表示" => "Time display", "文字を小さく" => "Smaller text", "文字を大きく" => "Larger text",
57
+ "ファイルを開く" => "Open file", "キャプチャを停止してから操作してください。" => "Stop the capture before continuing.", "キャプチャ中" => "Capturing",
58
+ "このキャプチャはまだ保存されていません。" => "This capture has not been saved.", "破棄" => "Discard", "キャプチャを保存しますか?" => "Save this capture?",
59
+ "指定したパケットは表示されていません" => "The specified packet is not displayed", "%{message} (位置 %{position})" => "%{message} (position %{position})",
60
+ "フィルタとしてコピー" => "Copy as filter", "フィルタを準備" => "Prepare filter", "フィルタの名前" => "Filter name",
61
+ "プロファイルの管理" => "Manage profiles", "複製" => "Duplicate", "新しいプロファイル名" => "New profile name", "新規作成" => "Create profile", "例: udp.port==8443,dns" => "Example: udp.port==8443,dns",
62
+ "Ruby ディセクタのパス" => "Ruby dissector path", "Ruby プラグインはユーザー権限で任意のコードを実行します。信頼するファイルだけを読み込んでください。\n%{paths}" => "Ruby plugins can execute arbitrary code as your user. Load only files you trust.\n%{paths}",
63
+ "信頼して読み込む" => "Trust and load", "プラグインの確認" => "Confirm plugins", "コマンドを検索" => "Search commands", "解析" => "Analysis", "名前解決" => "Name resolution",
64
+ "キャプチャファイルを開くか、インタフェースを選んで開始してください" => "Open a capture file or select an interface to start capturing.",
65
+ "読み込み中" => "Loading", "%{state} %{count} / %{total} 表示 %{displayed} ドロップ %{dropped}" => "%{state} %{count} / %{total} Displayed %{displayed} Dropped %{dropped}",
66
+ "操作を完了できません" => "The operation could not be completed", "読み込みを中止" => "Cancel loading", "クリア" => "Clear", "履歴" => "History",
67
+ "パケットキャプチャを開くか、インタフェースを選択してキャプチャを開始します。" => "Open a packet capture or select an interface to start capturing.",
68
+ "キャプチャファイルを開く" => "Open capture file", "インタフェースを選択" => "Select interface",
69
+ "上へ移動" => "Move up", "下へ移動" => "Move down", "項目名" => "Column name", "表示フィールド" => "Display field", "列幅" => "Column width",
70
+ "列として適用" => "Apply as column", "カスタム列を追加" => "Add custom column", "カスタム列" => "Custom columns", "列の名前" => "Column label", "フィールド名" => "Field name",
71
+ "時刻" => "Time", "送信元" => "Source", "宛先" => "Destination", "プロトコル" => "Protocol", "長さ" => "Length", "情報" => "Info",
72
+ "重大度" => "Severity", "コード" => "Code", "件数" => "Count", "メッセージ" => "Message", "アドレス A" => "Address A", "ポート A" => "Port A", "アドレス B" => "Address B", "ポート B" => "Port B",
73
+ "パケット A → B" => "Packets A → B", "パケット B → A" => "Packets B → A", "バイト A → B" => "Bytes A → B", "バイト B → A" => "Bytes B → A",
74
+ "アドレス" => "Address", "ポート" => "Port", "送信パケット" => "Transmitted packets", "受信パケット" => "Received packets", "送信バイト" => "Transmitted bytes", "受信バイト" => "Received bytes", "期間(ns)" => "Duration (ns)",
75
+ "言語" => "Language", "文字サイズ" => "Font size", "時刻の精度" => "Time precision", "自動スクロール" => "Autoscroll", "行キャッシュ容量" => "Row cache size",
76
+ "チェックサム検証" => "Verify checksums", "解析メモリ上限(MiB)" => "Analysis memory limit (MiB)", "フロー数上限" => "Maximum flows", "ワーカー数" => "Workers",
77
+ "キャプチャ方式" => "Capture backend", "ヘルパー起動方式" => "Helper launcher", "解決タイムアウト(秒)" => "Resolution timeout (seconds)", "名前キャッシュ容量" => "Name cache size",
78
+ "保存せずに閉じる" => "Close without saving", "復旧可能なセッション" => "Recoverable sessions", "復旧" => "Recover", "一時セッションを復旧しますか?" => "Recover temporary sessions?",
79
+ "相対時刻" => "Relative time", "絶対時刻" => "Absolute time", "前パケットとの差" => "Delta from previous packet", "前表示パケットとの差" => "Delta from previous displayed packet", "エポック時刻" => "Epoch time",
80
+ "システム設定" => "System", "ハイコントラスト" => "High contrast", "ミリ秒" => "Milliseconds", "マイクロ秒" => "Microseconds", "ナノ秒" => "Nanoseconds",
81
+ "前回のキャプチャを復旧" => "Recover previous capture",
82
+ "%{label} %{count} / %{total} 表示 %{displayed} ドロップ %{dropped}%{progress}" => "%{label} %{count} / %{total} Displayed %{displayed} Dropped %{dropped}%{progress}",
83
+ "幅" => "Width", "列名 (空欄: フィールド名)" => "Column label (blank: field name)", "幅は40から4096の範囲で指定してください" => "Width must be between 40 and 4096",
84
+ "%{protocol} %{packets} パケット %{bytes} bytes %{percent}%" => "%{protocol} %{packets} packets %{bytes} bytes %{percent}%",
85
+ "区間ごとのパケット数" => "Packets per interval", "%{count} パケット" => "%{count} packets", "キャプチャファイル" => "Capture files", "閉じる" => "Close",
86
+ "取得バッファ (bytes)" => "Capture buffer (bytes)", "取得方向" => "Capture direction",
87
+ "自動停止: パケット数 (0: 無効)" => "Stop after packet count (0: disabled)", "自動停止: 秒 (0: 無効)" => "Stop after seconds (0: disabled)", "自動停止: bytes (0: 無効)" => "Stop after bytes (0: disabled)",
88
+ "リング保存先 (空欄: 無効)" => "Ring file path (blank: disabled)", "リング: ファイルごとのbytes" => "Ring: bytes per file", "リング: 秒" => "Ring: seconds", "リング: 保持ファイル数" => "Ring: retained files",
89
+ "検索形式" => "Search mode", "キャプチャ操作" => "Capture controls", "日本語" => "Japanese", "英語" => "English", "自動" => "Automatic", "直接" => "Direct",
90
+ "マーク" => "Mark", "マークを解除" => "Unmark", "無視" => "Ignore", "無視を解除" => "Unignore", "時刻基準にする" => "Set time reference", "時刻基準を解除" => "Unset time reference", "双方向" => "Both directions", "フィルタ履歴はまだありません。" => "There is no filter history yet."
91
+ }.freeze #: Hash[String, String]
92
+ JA = EN.invert.freeze #: Hash[String, String]
93
+
94
+ # @rbs (String text, ?language: String, **untyped values) -> String
95
+ def self.translate(text, language: "ja", **values)
96
+ dictionary = case language
97
+ when "ja" then JA
98
+ when "en" then EN
99
+ else raise Vanken::ConfigError, "invalid language: #{language}"
100
+ end
101
+ dictionary.fetch(text, text).gsub(/%\{(\w+)\}/) { values.fetch(Regexp.last_match(1).to_sym).to_s }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -5,6 +5,7 @@ require "yaml"
5
5
  require "fileutils"
6
6
  require "logger"
7
7
  require "tempfile"
8
+ require_relative "yaml_file"
8
9
 
9
10
  module Vanken
10
11
  module Config
@@ -25,22 +26,32 @@ module Vanken
25
26
  class Preferences
26
27
  DEFAULTS = {
27
28
  "schema_version" => 1,
28
- "appearance" => {"theme" => "system", "font_size" => 13, "language" => "ja"},
29
- "packet_list" => {"time_format" => "relative", "time_precision" => "micro", "autoscroll" => true, "row_cache_rows" => 20_000},
30
- "capture" => {"snaplen" => 262_144, "promiscuous" => true, "buffer_size" => 4 << 20, "backend" => "auto", "launcher" => "auto", "direction" => "inout"},
29
+ "appearance" => {"theme" => "system", "font_size" => 13, "language" => ENV.fetch("LANG", "en").start_with?("ja") ? "ja" : "en"},
30
+ "packet_list" => {"time_format" => "relative", "time_precision" => "micro", "autoscroll" => true, "row_cache_rows" => 20_000, "coloring" => true},
31
+ "capture" => {"snaplen" => 262_144, "promiscuous" => true, "buffer_size" => 4 << 20, "backend" => "auto", "launcher" => "auto", "direction" => "inout",
32
+ "stop_count" => 0, "stop_duration" => 0.0, "stop_bytes" => 0, "ring_path" => "", "ring_max_bytes" => 0, "ring_interval" => 0.0, "ring_file_count" => 10},
33
+ "name_resolution" => {"enabled" => false, "timeout" => 1.0, "cache_size" => 256},
31
34
  "analysis" => {"verify_checksums" => false, "max_state_mib" => 256, "max_flows" => 100_000, "workers" => 4},
32
35
  "history" => [], "recent_files" => [], "bookmarks" => {}, "layout" => {"ratios" => [0.55, 0.6], "width" => 1280, "height" => 800, "columns" => []}
33
36
  }.freeze
34
37
  attr_reader :warning, :directory
35
38
 
36
- def initialize(directory: Paths.config)
39
+ def initialize(directory: Paths.config, state_directory: nil)
37
40
  @directory = directory
41
+ @state_directory = state_directory || (directory == Paths.config ? Paths.state : File.join(directory, "state"))
38
42
  @values = Marshal.load(Marshal.dump(DEFAULTS))
39
43
  path = File.join(directory, "preferences.yml")
40
- return unless File.file?(path)
41
- saved = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
42
- raise Vanken::ConfigError, "unsupported preferences schema" unless saved.is_a?(Hash) && saved["schema_version"] == 1
43
- merge(@values, saved)
44
+ if File.file?(path)
45
+ saved = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
46
+ raise Vanken::ConfigError, "unsupported preferences schema" unless saved.is_a?(Hash) && saved["schema_version"] == 1
47
+ merge(@values, saved)
48
+ end
49
+ state = YamlFile.read(File.join(@state_directory, "recent.yml"))
50
+ merge(@values, state.slice("history", "recent_files"))
51
+ window = YamlFile.read(File.join(@state_directory, "window_state.yml"))
52
+ merge(@values["layout"], window.slice("width", "height", "ratios"))
53
+ filters = YamlFile.read(File.join(@directory, "filters.yml"))
54
+ merge(@values, filters.slice("bookmarks"))
44
55
  validate
45
56
  rescue Psych::Exception, Vanken::ConfigError, SystemCallError, TypeError => error
46
57
  @values = Marshal.load(Marshal.dump(DEFAULTS))
@@ -78,6 +89,9 @@ module Vanken
78
89
  temporary.fsync
79
90
  temporary.close
80
91
  File.rename(temporary.path, File.join(@directory, "preferences.yml"))
92
+ YamlFile.write(File.join(@state_directory, "recent.yml"), @values.slice("history", "recent_files"))
93
+ YamlFile.write(File.join(@state_directory, "window_state.yml"), @values["layout"].slice("width", "height", "ratios"))
94
+ YamlFile.write(File.join(@directory, "filters.yml"), @values.slice("bookmarks"))
81
95
  ensure
82
96
  temporary&.close unless temporary&.closed?
83
97
  File.unlink(temporary.path) if temporary && File.exist?(temporary.path)
@@ -99,6 +113,7 @@ module Vanken
99
113
  validate_shape(DEFAULTS, @values)
100
114
  raise Vanken::ConfigError, "invalid theme" unless %w[system dark light high_contrast].include?(get("appearance.theme"))
101
115
  raise Vanken::ConfigError, "invalid font size" unless get("appearance.font_size").is_a?(Integer) && get("appearance.font_size").between?(8, 32)
116
+ raise Vanken::ConfigError, "invalid language" unless %w[ja en].include?(get("appearance.language"))
102
117
  raise Vanken::ConfigError, "invalid time format" unless %w[relative absolute delta delta_displayed epoch].include?(get("packet_list.time_format"))
103
118
  raise Vanken::ConfigError, "invalid time precision" unless %w[milli micro nano].include?(get("packet_list.time_precision"))
104
119
  raise Vanken::ConfigError, "invalid history" unless history.all? { |item| item.is_a?(String) } && recent_files.all? { |item| item.is_a?(String) } && bookmarks.all? { |name, expression| name.is_a?(String) && expression.is_a?(String) }
@@ -111,6 +126,17 @@ module Vanken
111
126
  columns = get("layout.columns")
112
127
  raise Vanken::ConfigError, "invalid columns" unless columns.all? { |column| column.is_a?(Hash) && %w[no time source destination protocol length info].include?(column["key"]) && column["width"].is_a?(Numeric) && column["width"].finite? && column["width"].between?(40, 4096) && [true, false].include?(column["visible"]) } && columns.map { |column| column["key"] }.uniq.size == columns.size
113
128
  raise Vanken::ConfigError, "invalid capture settings" unless %w[auto direct sudo pkexec].include?(get("capture.launcher")) && %w[auto ring socket bpf].include?(get("capture.backend")) && %w[in out inout].include?(get("capture.direction"))
129
+ %w[capture.stop_count capture.stop_bytes capture.ring_max_bytes].each do |path|
130
+ raise ConfigError, "invalid #{path}" unless get(path).is_a?(Integer) && get(path).between?(0, (1 << 63) - 1)
131
+ end
132
+ %w[capture.stop_duration capture.ring_interval].each do |path|
133
+ value = get(path)
134
+ raise ConfigError, "invalid #{path}" unless value.is_a?(Numeric) && value.finite? && value >= 0
135
+ end
136
+ raise ConfigError, "invalid ring file count" unless get("capture.ring_file_count").between?(1, 10_000)
137
+ raise ConfigError, "invalid ring path" if get("capture.ring_path").include?("\0")
138
+ raise ConfigError, "invalid resolver timeout" unless get("name_resolution.timeout").finite? && get("name_resolution.timeout").between?(0.01, 10)
139
+ raise ConfigError, "invalid resolver cache size" unless get("name_resolution.cache_size").between?(1, 10_000)
114
140
  rescue NoMethodError, KeyError
115
141
  raise Vanken::ConfigError, "invalid preferences shape"
116
142
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "preferences"
4
+ require_relative "yaml_file"
5
+
6
+ module Vanken
7
+ module Config
8
+ class Profiles
9
+ FILES = %w[preferences.yml coloring_rules.yml columns.yml filters.yml decode_as.yml plugins.yml].freeze
10
+ attr_reader :directory, :active
11
+
12
+ def initialize(directory: Paths.config)
13
+ @directory = File.expand_path(directory)
14
+ @active = YamlFile.read(File.join(@directory, "profiles.yml"), default: {"active" => "default"}).fetch("active", "default")
15
+ profile_directory(@active)
16
+ @active = "default" unless names.include?(@active)
17
+ end
18
+
19
+ def names
20
+ ["default", *Dir.glob(File.join(@directory, "profiles", "*")).select { |path| File.directory?(path) && !File.symlink?(path) }.map { |path| File.basename(path) }.sort]
21
+ end
22
+
23
+ def preferences
24
+ shared = @directory == File.expand_path(Paths.config) ? Paths.state : File.join(@directory, "state")
25
+ Preferences.new(directory: profile_directory(@active), state_directory: shared)
26
+ end
27
+
28
+ def create(name, copy_from: nil)
29
+ target = profile_directory(name)
30
+ raise ConfigError, "profile already exists" if names.include?(name) || File.exist?(target) || File.symlink?(target)
31
+ raise ConfigError, "unknown profile" if copy_from && !names.include?(copy_from)
32
+ FileUtils.mkdir_p(File.dirname(target), mode: 0o700)
33
+ Dir.mkdir(target, 0o700)
34
+ if copy_from
35
+ source = profile_directory(copy_from)
36
+ FILES.each do |file|
37
+ path = File.join(source, file)
38
+ next unless File.file?(path)
39
+ raise ConfigError, "profile file must not be a symlink" if File.symlink?(path)
40
+ File.write(File.join(target, file), File.binread(path), mode: "wb", perm: 0o600)
41
+ end
42
+ else
43
+ Preferences.new(directory: target).save
44
+ end
45
+ name
46
+ end
47
+
48
+ def switch(name)
49
+ profile_directory(name)
50
+ raise ConfigError, "unknown profile" unless names.include?(name)
51
+ @active = name
52
+ YamlFile.write(File.join(@directory, "profiles.yml"), "active" => name)
53
+ preferences
54
+ end
55
+
56
+ def delete(name)
57
+ path = profile_directory(name)
58
+ raise ConfigError, "cannot delete the active or default profile" if name == @active || name == "default"
59
+ raise ConfigError, "unknown profile" unless names.include?(name)
60
+ FileUtils.remove_entry_secure(path)
61
+ end
62
+
63
+ private
64
+
65
+ def profile_directory(name)
66
+ raise ConfigError, "invalid profile name" unless name.is_a?(String) && name.match?(/\A[\p{L}\p{N}_ -][\p{L}\p{N}_. -]{0,63}\z/) && !%w[. ..].include?(name)
67
+ path = name == "default" ? @directory : File.join(@directory, "profiles", name)
68
+ raise ConfigError, "profiles directory must not be a symlink" if name != "default" && File.symlink?(File.join(@directory, "profiles"))
69
+ raise ConfigError, "profile must not be a symlink" if File.symlink?(path)
70
+ path
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "json"
5
+
6
+ module Vanken
7
+ module Config
8
+ module Sessions
9
+ def self.candidates(parent: Dir.tmpdir)
10
+ Dir.glob(File.join(parent, "vanken-*" )).select { |directory| orphan?(directory) }.sort
11
+ end
12
+
13
+ def self.recover(directory, parent: Dir.tmpdir, **options)
14
+ with_session(directory, parent: parent) { App::Document.recover(directory, **options) }
15
+ end
16
+
17
+ def self.discard(directory, parent: Dir.tmpdir)
18
+ store = with_session(directory, parent: parent) { Core::FrameStore.recover(directory) }
19
+ store.close
20
+ end
21
+
22
+ def self.orphan?(directory)
23
+ return false unless File.directory?(directory) && !File.symlink?(directory)
24
+ stat = File.stat(directory)
25
+ return false unless stat.uid == Process.uid && (stat.mode & 0o077).zero?
26
+ Dir.children(directory).each do |name|
27
+ stat = File.lstat(File.join(directory, name))
28
+ return false unless stat.file? && stat.uid == Process.uid && stat.nlink == 1
29
+ end
30
+ path = File.join(directory, "session.json")
31
+ return false unless File.file?(path) && File.size(path) <= 65_536
32
+ return false unless %w[frames.bin frames.idx].all? { |name| File.file?(File.join(directory, name)) }
33
+ session = JSON.parse(File.read(path))
34
+ pid = session["pid"]
35
+ return false unless session["schema_version"] == 1 && pid.is_a?(Integer) && pid.positive?
36
+ begin
37
+ Process.kill(0, pid)
38
+ false
39
+ rescue Errno::ESRCH
40
+ true
41
+ rescue Errno::EPERM
42
+ false
43
+ end
44
+ rescue SystemCallError, JSON::ParserError, TypeError, NoMethodError
45
+ false
46
+ end
47
+
48
+ def self.with_session(directory, parent:)
49
+ path = File.expand_path(directory)
50
+ raise Vanken::FileError, "session is active or unsafe" unless candidates(parent: parent).include?(path)
51
+ File.open(File.join(path, "session.json"), "r+b") do |file|
52
+ raise Vanken::FileError, "session is being recovered" unless file.flock(File::LOCK_EX | File::LOCK_NB)
53
+ raise Vanken::FileError, "session is active or unsafe" unless orphan?(path)
54
+ original = file.read
55
+ begin
56
+ yield
57
+ rescue StandardError
58
+ file.rewind
59
+ file.truncate(0)
60
+ file.write(original)
61
+ file.flush
62
+ raise
63
+ end
64
+ end
65
+ end
66
+ private_class_method :orphan?, :with_session
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "tempfile"
5
+ require "fileutils"
6
+
7
+ module Vanken
8
+ module Config
9
+ module YamlFile
10
+ def self.read(path, default: {})
11
+ return default unless File.file?(path)
12
+ raise ConfigError, "configuration exceeds 1 MiB" if File.size(path) > 1 << 20
13
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
14
+ raise ConfigError, "unsupported configuration schema: #{path}" unless value.is_a?(Hash) && value["schema_version"] == 1
15
+ value
16
+ rescue Psych::Exception => error
17
+ raise ConfigError, error.message
18
+ end
19
+
20
+ def self.write(path, value)
21
+ directory = File.dirname(path)
22
+ FileUtils.mkdir_p(directory, mode: 0o700)
23
+ temporary = Tempfile.create(["vanken-", ".yml"], directory)
24
+ temporary.chmod(0o600)
25
+ temporary.write(YAML.dump(value.merge("schema_version" => 1)))
26
+ temporary.flush
27
+ temporary.fsync
28
+ temporary.close
29
+ File.rename(temporary.path, path)
30
+ ensure
31
+ temporary&.close unless temporary&.closed?
32
+ File.unlink(temporary.path) if temporary && File.exist?(temporary.path)
33
+ end
34
+ end
35
+ end
36
+ end