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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +28 -11
  4. data/data/coloring_rules.yml +67 -0
  5. data/docs/performance.md +53 -0
  6. data/docs/releases.md +2 -4
  7. data/docs/usage.md +51 -0
  8. data/exe/vanken-setup-permissions +6 -0
  9. data/lib/vanken/app/capture_controller.rb +66 -1
  10. data/lib/vanken/app/document.rb +102 -39
  11. data/lib/vanken/app/document_analysis.rb +75 -0
  12. data/lib/vanken/app/document_jobs.rb +52 -9
  13. data/lib/vanken/app/expert_info.rb +26 -0
  14. data/lib/vanken/app/io_graph.rb +47 -0
  15. data/lib/vanken/app/navigation.rb +77 -0
  16. data/lib/vanken/app/search_job.rb +62 -0
  17. data/lib/vanken/app/stats_job.rb +45 -0
  18. data/lib/vanken/capture/analyzer.rb +3 -1
  19. data/lib/vanken/capture/analyzer_process.rb +9 -6
  20. data/lib/vanken/capture/analyzer_worker.rb +2 -2
  21. data/lib/vanken/capture/filter_worker.rb +8 -2
  22. data/lib/vanken/capture/helper_main.rb +10 -3
  23. data/lib/vanken/capture/helper_options.rb +6 -2
  24. data/lib/vanken/capture/launcher.rb +1 -0
  25. data/lib/vanken/capture/permission_setup.rb +201 -0
  26. data/lib/vanken/config/analysis_settings.rb +58 -0
  27. data/lib/vanken/config/columns.rb +138 -0
  28. data/lib/vanken/config/messages.rb +105 -0
  29. data/lib/vanken/config/preferences.rb +34 -8
  30. data/lib/vanken/config/profiles.rb +74 -0
  31. data/lib/vanken/config/sessions.rb +69 -0
  32. data/lib/vanken/config/yaml_file.rb +36 -0
  33. data/lib/vanken/core/coloring.rb +86 -0
  34. data/lib/vanken/core/frame_store.rb +2 -2
  35. data/lib/vanken/core/stores.rb +12 -0
  36. data/lib/vanken/gateway/detail_builder.rb +1 -1
  37. data/lib/vanken/gateway/display_capture_filter.rb +102 -0
  38. data/lib/vanken/gateway/dissector.rb +31 -6
  39. data/lib/vanken/gateway/exporter.rb +125 -0
  40. data/lib/vanken/gateway/file_writer.rb +26 -2
  41. data/lib/vanken/gateway/interfaces.rb +59 -0
  42. data/lib/vanken/gateway/resolver.rb +131 -0
  43. data/lib/vanken/gateway/statistics.rb +83 -0
  44. data/lib/vanken/gateway/stream.rb +159 -0
  45. data/lib/vanken/ui/actions.rb +54 -28
  46. data/lib/vanken/ui/analysis_dialogs.rb +371 -0
  47. data/lib/vanken/ui/application.rb +183 -9
  48. data/lib/vanken/ui/coloring_operations.rb +104 -0
  49. data/lib/vanken/ui/column_operations.rb +115 -0
  50. data/lib/vanken/ui/dialogs.rb +41 -26
  51. data/lib/vanken/ui/file_operations.rb +17 -12
  52. data/lib/vanken/ui/filter_operations.rb +43 -15
  53. data/lib/vanken/ui/main_view.rb +74 -29
  54. data/lib/vanken/ui/navigation_operations.rb +86 -0
  55. data/lib/vanken/ui/packet_source.rb +33 -1
  56. data/lib/vanken/ui/selection.rb +8 -2
  57. data/lib/vanken/ui/settings_operations.rb +233 -0
  58. data/lib/vanken/version.rb +1 -1
  59. data/packaging/README.md +41 -1
  60. data/packaging/linux/org.vanken.capture.policy +14 -0
  61. data/packaging/linux/vanken-capture.wrapper +6 -0
  62. data/packaging/linux/vanken.desktop +9 -0
  63. data/packaging/linux/vanken.sudoers +2 -0
  64. data/packaging/macos/chmod-bpf +6 -0
  65. data/packaging/macos/org.vanken.chmod-bpf.plist +8 -0
  66. data/sig/generated/vanken/app/capture_controller.rbs +16 -0
  67. data/sig/generated/vanken/app/document.rbs +23 -1
  68. data/sig/generated/vanken/app/document_analysis.rbs +21 -0
  69. data/sig/generated/vanken/app/document_jobs.rbs +6 -0
  70. data/sig/generated/vanken/app/expert_info.rbs +30 -0
  71. data/sig/generated/vanken/app/io_graph.rbs +45 -0
  72. data/sig/generated/vanken/app/navigation.rbs +31 -0
  73. data/sig/generated/vanken/app/search_job.rbs +21 -0
  74. data/sig/generated/vanken/app/stats_job.rbs +18 -0
  75. data/sig/generated/vanken/capture/helper_options.rbs +6 -0
  76. data/sig/generated/vanken/capture/permission_setup.rbs +43 -0
  77. data/sig/generated/vanken/config/analysis_settings.rbs +31 -0
  78. data/sig/generated/vanken/config/columns.rbs +37 -0
  79. data/sig/generated/vanken/config/messages.rbs +14 -0
  80. data/sig/generated/vanken/config/preferences.rbs +1 -1
  81. data/sig/generated/vanken/config/profiles.rbs +29 -0
  82. data/sig/generated/vanken/config/sessions.rbs +17 -0
  83. data/sig/generated/vanken/config/yaml_file.rbs +11 -0
  84. data/sig/generated/vanken/core/coloring.rbs +52 -0
  85. data/sig/generated/vanken/core/stores.rbs +4 -0
  86. data/sig/generated/vanken/gateway/display_capture_filter.rbs +37 -0
  87. data/sig/generated/vanken/gateway/dissector.rbs +15 -2
  88. data/sig/generated/vanken/gateway/exporter.rbs +27 -0
  89. data/sig/generated/vanken/gateway/file_writer.rbs +3 -0
  90. data/sig/generated/vanken/gateway/interfaces.rbs +12 -0
  91. data/sig/generated/vanken/gateway/resolver.rbs +52 -0
  92. data/sig/generated/vanken/gateway/statistics.rbs +104 -0
  93. data/sig/generated/vanken/gateway/stream.rbs +75 -0
  94. data/sig/generated/vanken/ui/analysis_dialogs.rbs +71 -0
  95. data/sig/generated/vanken/ui/application.rbs +23 -1
  96. data/sig/generated/vanken/ui/coloring_operations.rbs +29 -0
  97. data/sig/generated/vanken/ui/column_operations.rbs +29 -0
  98. data/sig/generated/vanken/ui/dialogs.rbs +5 -3
  99. data/sig/generated/vanken/ui/file_operations.rbs +2 -0
  100. data/sig/generated/vanken/ui/filter_operations.rbs +3 -1
  101. data/sig/generated/vanken/ui/main_view.rbs +14 -2
  102. data/sig/generated/vanken/ui/navigation_operations.rbs +43 -0
  103. data/sig/generated/vanken/ui/packet_source.rbs +4 -0
  104. data/sig/generated/vanken/ui/settings_operations.rbs +41 -0
  105. metadata +61 -3
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../gateway/statistics"
4
+
5
+ module Vanken
6
+ module App
7
+ # Invoke refresh on the executor; cancel joins its current frame before store close.
8
+ class StatsJob
9
+ def initialize(document, specs: ["phs"], displayed_only: false)
10
+ @document, @specs, @displayed_only = document, specs, displayed_only
11
+ @mutex, @numbers, @cancelled = Mutex.new, [], false
12
+ reset
13
+ end
14
+
15
+ def refresh
16
+ @mutex.synchronize do
17
+ return [] if @cancelled || @document.closing?
18
+ numbers = @displayed_only ? @document.display_numbers.sort : (1..@document.count).to_a
19
+ reset unless numbers.take(@numbers.size) == @numbers
20
+ numbers.drop(@numbers.size).each do |number|
21
+ break if @cancelled || @document.closing?
22
+ @statistics.update(@dissector.dissect(@document.store.read(number)))
23
+ @numbers << number
24
+ end
25
+ @statistics.tables
26
+ end
27
+ end
28
+
29
+ def cancel
30
+ @cancelled = true
31
+ @mutex.synchronize { @statistics.close }
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def reset
38
+ @statistics&.close
39
+ @numbers = []
40
+ @dissector = Gateway::Dissector.new(**@document.analysis_gateway_options)
41
+ @statistics = Gateway::Statistics.new(specs: @specs, registry: @dissector.registry, **@document.analysis_options)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -11,7 +11,7 @@ module Vanken
11
11
  def run
12
12
  number = 1
13
13
  loop do
14
- break if @document.respond_to?(:closing?) && @document.closing?
14
+ break if @document.analysis_stopped?
15
15
  if number > @document.store.durable_count
16
16
  break if @document.received? && number > @document.store.durable_count
17
17
  @document.wait_for_frames
@@ -20,11 +20,13 @@ module Vanken
20
20
  frame = @document.store.read(number)
21
21
  begin
22
22
  packet = @dissector.dissect(frame)
23
+ break if @document.analysis_stopped?
23
24
  @analysis.update(packet)
24
25
  rescue StandardError => error
25
26
  @document.publish_failure(number, error)
26
27
  packet = nil
27
28
  end
29
+ break if @document.analysis_stopped?
28
30
  @document.publish(number, packet) if packet
29
31
  number += 1
30
32
  if number % 256 == 0
@@ -11,6 +11,7 @@ module Vanken
11
11
  @document, @verify_checksums = document, verify_checksums
12
12
  end
13
13
  def run
14
+ return if @document.analysis_stopped?
14
15
  unless Gem.win_platform?
15
16
  raise Vanken::Error, "packet analysis must run without root privileges" if Process.uid.zero? || Process.euid.zero?
16
17
  end
@@ -23,10 +24,11 @@ module Vanken
23
24
  @pid = Process.spawn(*arguments, in: child_in, out: child_out, close_others: true)
24
25
  child_in.close
25
26
  child_out.close
26
- request(command: :start, spool: @document.store.directory, verify_checksums: @verify_checksums, analysis_options: @document.analysis_options)
27
+ request(command: :start, spool: @document.store.directory, verify_checksums: @verify_checksums, analysis_options: @document.analysis_options,
28
+ gateway_options: @document.analysis_gateway_options)
27
29
  number = 1
28
30
  loop do
29
- break if @document.closing?
31
+ break if @document.analysis_stopped?
30
32
  limit = @document.store.durable_count
31
33
  if number > limit
32
34
  break if @document.received? && number > @document.store.durable_count
@@ -36,13 +38,14 @@ module Vanken
36
38
  last = [number + 255, limit].min
37
39
  configuration = @document.analysis_configuration(number, last)
38
40
  batch = request(configuration.merge(command: :analyze, first: number, last: last, interfaces: @document.store.interfaces))
41
+ break if @document.analysis_stopped?
39
42
  raise Vanken::Error, "analysis response range mismatch" unless batch[:first] == number && batch[:last] == last
40
43
  @document.publish_analysis_batch(batch, self, configuration[:generation], context_token: configuration[:context_token])
41
44
  number = last + 1
42
45
  end
43
46
  rescue StandardError
44
- @document.cancel
45
- raise unless @document.closing?
47
+ @document.cancel unless @document.analysis_stopped?
48
+ raise unless @document.analysis_stopped?
46
49
  ensure
47
50
  child_in&.close unless child_in&.closed?
48
51
  child_out&.close unless child_out&.closed?
@@ -54,7 +57,7 @@ module Vanken
54
57
  end
55
58
  def request(value)
56
59
  AnalyzerWire.write(@input, value)
57
- response = AnalyzerWire.read(@output, cancelled: -> { @document.closing? })
60
+ response = AnalyzerWire.read(@output, cancelled: -> { @document.analysis_stopped? })
58
61
  raise Vanken::Error, response[:error] if response[:error]
59
62
  response
60
63
  end
@@ -82,7 +85,7 @@ module Vanken
82
85
  end
83
86
  sleep(0.005)
84
87
  end
85
- raise Vanken::Error, "analysis worker failed while stopping" unless status.success? || @document.closing? || $!
88
+ raise Vanken::Error, "analysis worker failed while stopping" unless status.success? || @document.analysis_stopped? || $!
86
89
  rescue Errno::ECHILD, Errno::ESRCH
87
90
  nil
88
91
  end
@@ -57,11 +57,11 @@ module Vanken
57
57
 
58
58
  def initialize(request)
59
59
  @reader = Reader.new(request.fetch(:spool))
60
- @dissector = Gateway::Dissector.new(verify_checksums: request.fetch(:verify_checksums))
60
+ @dissector = Gateway::Dissector.new(**request.fetch(:gateway_options, {verify_checksums: request.fetch(:verify_checksums)}))
61
61
  @analysis = Gateway::Analysis.new(registry: @dissector.registry, **request.fetch(:analysis_options))
62
62
  @columns = Core::ColumnStore.new
63
63
  @annotations = Core::AnnotationStore.new(request.fetch(:spool), persist: false)
64
- @catalog = Gateway::FieldCatalog.new
64
+ @catalog = Gateway::FieldCatalog.new(registry: @dissector.registry)
65
65
  @seen_fields = @catalog.names.to_set
66
66
  @context = Context.new(@reader)
67
67
  @next = 1
@@ -8,6 +8,7 @@ require_relative "../core/frame_store"
8
8
  require_relative "../core/stores"
9
9
  require_relative "../core/display_filter/compiler"
10
10
  require_relative "../gateway/dissector"
11
+ require_relative "../gateway/display_capture_filter"
11
12
 
12
13
  module Vanken
13
14
  module Capture
@@ -153,12 +154,17 @@ module Vanken
153
154
  raise ArgumentError, "filter chunk exceeds stored frames" unless last <= reader.count + 1
154
155
 
155
156
  dissector = Gateway::Dissector.new(decode_as: payload.fetch("decode_as", []), plugins: payload.fetch("plugins", []))
156
- program = Core::DisplayFilter.compile(payload.fetch("expr"), catalog: Gateway::FieldCatalog.new)
157
+ program = Core::DisplayFilter.compile(payload.fetch("expr"), catalog: Gateway::FieldCatalog.new(registry: dissector.registry))
158
+ capture = Gateway::DisplayCaptureFilter.new(payload.fetch("expr")) if payload.fetch("plugins", []).empty? && payload.fetch("decode_as", []).empty?
159
+ capture = nil unless capture&.expression
157
160
  context = {marked: Set.new(payload.fetch("marked", [])), ignored: Set.new(payload.fetch("ignored", [])),
158
161
  reference: payload.fetch("time_reference_ns") { reader.count.zero? ? 0 : reader.metadata(1)[:timestamp_ns] },
159
162
  references: payload.fetch("time_references_ns", {}).map { |number, timestamp| [Integer(number), timestamp] }.sort_by(&:first),
160
163
  predecessors: payload.fetch("displayed_predecessors", {})}
161
- matches = (first...last).select { |number| program.match?(View.new(reader, number, dissector, context)) }
164
+ matches = (first...last).select do |number|
165
+ matched = capture&.match(reader.frame(number))
166
+ matched.nil? ? program.match?(View.new(reader, number, dissector, context)) : matched
167
+ end
162
168
  {"matches" => matches}
163
169
  ensure
164
170
  reader&.close
@@ -23,7 +23,7 @@ module Vanken
23
23
  when :list_interfaces then @stdout.puts(JSON.generate(Gateway::Interfaces.list)); return 0
24
24
  when :check then return check(options)
25
25
  when :version then @stdout.puts("Vanken #{VERSION}"); return 0
26
- when :help then @stdout.puts("vanken-capture -i INTERFACE [--filter EXPR] [--snaplen N] [--no-promiscuous]\n [--buffer-size BYTES] [--direction in|out|inout] [--backend auto|ring|socket|bpf]\n [--drop-to UID:GID] [--stats-interval SEC] [--flush-interval SEC]\n --list-interfaces | --check [--interface IF] | --version"); return 0
26
+ when :help then @stdout.puts("vanken-capture -i INTERFACE [--filter EXPR] [--snaplen N] [--no-promiscuous]\n [--buffer-size BYTES] [--direction in|out|inout] [--backend auto|ring|socket|bpf]\n [--drop-to UID:GID] [--stats-interval SEC] [--flush-interval SEC]\n [--stop-count N] [--stop-duration SEC] [--stop-bytes BYTES]\n --list-interfaces | --check [--interface IF] | --version"); return 0
27
27
  end
28
28
 
29
29
  capture(options)
@@ -64,11 +64,18 @@ module Vanken
64
64
  end
65
65
  @control.write(:started, interface: options.capture[:interface], linktype: source.linktype,
66
66
  snaplen: options.capture[:snaplen], backend: source.backend, filter: options.capture[:filter], privileges_dropped: dropped)
67
- flushed_at = stats_at = monotonic
67
+ started_at = flushed_at = stats_at = monotonic
68
+ captured = 0
68
69
  until @stop_reason || source.stopped?
69
70
  packet = source.next_packet(timeout: 0.05)
70
- writer << packet if packet
71
+ if packet
72
+ writer << packet
73
+ captured += 1
74
+ end
71
75
  now = monotonic
76
+ @stop_reason ||= "count_limit" if options.stop_count && captured >= options.stop_count
77
+ @stop_reason ||= "duration_limit" if options.stop_duration && now - started_at >= options.stop_duration
78
+ @stop_reason ||= "bytes_limit" if options.stop_bytes && writer.bytes_written >= options.stop_bytes
72
79
  if now - flushed_at >= options.flush_interval
73
80
  writer.flush
74
81
  flushed_at = now
@@ -5,7 +5,8 @@ require "optparse"
5
5
  module Vanken
6
6
  module Capture
7
7
  class HelperOptions
8
- attr_reader :command, :capture, :drop_to, :stats_interval, :flush_interval
8
+ attr_reader :command, :capture, :drop_to, :stats_interval, :flush_interval,
9
+ :stop_count, :stop_duration, :stop_bytes
9
10
 
10
11
  def self.parse(argv)
11
12
  new.parse(argv)
@@ -36,13 +37,16 @@ module Vanken
36
37
  end
37
38
  opts.on("--stats-interval SEC", Float) { |v| @stats_interval = bounded(v, 0.01..60, "stats-interval") }
38
39
  opts.on("--flush-interval SEC", Float) { |v| @flush_interval = bounded(v, 0.001..1, "flush-interval") }
40
+ opts.on("--stop-count N", Integer) { |v| @stop_count = bounded(v, 1..9_223_372_036_854_775_807, "stop-count") }
41
+ opts.on("--stop-duration SEC", Float) { |v| @stop_duration = bounded(v, 0.001..31_536_000, "stop-duration") }
42
+ opts.on("--stop-bytes N", Integer) { |v| @stop_bytes = bounded(v, 1..9_223_372_036_854_775_807, "stop-bytes") }
39
43
  opts.on("--list-interfaces") { modes << :list_interfaces }
40
44
  opts.on("--check") { modes << :check }
41
45
  opts.on("--version") { modes << :version }
42
46
  opts.on("-h", "--help") { modes << :help }
43
47
  end
44
48
  # OptionParser accepts long-option abbreviations by default; privileged inputs must be exact.
45
- known = %w[--interface --filter --snaplen --no-promiscuous --buffer-size --direction --backend --drop-to --stats-interval --flush-interval --list-interfaces --check --version --help]
49
+ known = %w[--interface --filter --snaplen --no-promiscuous --buffer-size --direction --backend --drop-to --stats-interval --flush-interval --stop-count --stop-duration --stop-bytes --list-interfaces --check --version --help]
46
50
  argv.each do |arg|
47
51
  raise ArgumentError, "unknown option: #{arg}" if arg.start_with?("--") && !known.include?(arg.split("=", 2).first)
48
52
  end
@@ -171,6 +171,7 @@ module Vanken
171
171
  def capture_argv(options)
172
172
  options.flat_map do |key, value|
173
173
  next [] if value.nil?
174
+ next [] if %i[stop_count stop_duration stop_bytes].include?(key.to_sym) && value == 0
174
175
  next value ? [] : ["--no-promiscuous"] if key.to_sym == :promiscuous
175
176
 
176
177
  ["--#{key.to_s.tr('_', '-')}", value.to_s]
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "etc"
6
+ require "shellwords"
7
+ require "tempfile"
8
+ require "open3"
9
+
10
+ module Vanken
11
+ module Capture
12
+ class PermissionSetup
13
+ PREFIX = "/usr/local/libexec/vanken"
14
+ SOURCE = File.expand_path("../../..", __dir__)
15
+
16
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
17
+ options = {}
18
+ dry_run = false
19
+ parser = OptionParser.new do |flags|
20
+ flags.banner = "Usage: vanken-setup-permissions [--dry-run] [--ruby PATH --gem-home PATH] [--policy polkit|sudoers] [--group GROUP]"
21
+ flags.on("--platform VALUE", %w[linux darwin]) { |value| options[:platform] = value.to_sym }
22
+ flags.on("--ruby PATH") { |value| options[:ruby] = value }
23
+ flags.on("--gem-home PATH") { |value| options[:gem_home] = value }
24
+ flags.on("--policy VALUE", %w[polkit sudoers]) { |value| options[:policy] = value.to_sym }
25
+ flags.on("--group GROUP") { |value| options[:group] = value }
26
+ flags.on("--root DIRECTORY", "Stage files without activating system permissions") { |value| options[:root] = value }
27
+ flags.on("--dry-run") { dry_run = true }
28
+ flags.on("-h", "--help") { stdout.puts(flags); return 0 }
29
+ end
30
+ known = %w[--platform --ruby --gem-home --policy --group --root --dry-run --help]
31
+ argv.each do |value|
32
+ raise ArgumentError, "unknown option: #{value}" if value.start_with?("--") && !known.include?(value.split("=", 2).first)
33
+ end
34
+ remaining = parser.parse(argv.dup)
35
+ raise ArgumentError, "unexpected arguments: #{remaining.join(' ')}" unless remaining.empty?
36
+ setup = new(**options)
37
+ setup.install unless dry_run
38
+ stdout.puts(JSON.pretty_generate(platform: setup.platform, dry_run: dry_run, staged: setup.staged?,
39
+ files: setup.plan.keys, next_steps: setup.next_steps))
40
+ 0
41
+ rescue ArgumentError, OptionParser::ParseError => error
42
+ stderr.puts(error.message)
43
+ 2
44
+ rescue SystemCallError, IOError => error
45
+ stderr.puts(error.message)
46
+ 1
47
+ end
48
+
49
+ attr_reader :platform
50
+
51
+ def initialize(platform: nil,
52
+ ruby: nil, gem_home: nil, policy: :polkit, group: "vanken", root: "/")
53
+ platform ||= RUBY_PLATFORM.include?("darwin") ? :darwin : (RUBY_PLATFORM.include?("linux") ? :linux : :unsupported)
54
+ @platform, @ruby, @gem_home, @policy, @group = platform.to_sym, ruby, gem_home, policy.to_sym, group
55
+ raise ArgumentError, "unsupported platform" unless %i[linux darwin].include?(@platform)
56
+ raise ArgumentError, "invalid policy" unless %i[polkit sudoers].include?(@policy)
57
+ raise ArgumentError, "invalid capture group" unless @group.match?(/\A[a-z_][a-z0-9_-]{0,31}\z/)
58
+ if @platform == :linux
59
+ [@ruby, @gem_home].each do |path|
60
+ raise ArgumentError, "Linux setup requires absolute --ruby and --gem-home paths" unless path&.start_with?("/") && !path.include?("\n")
61
+ end
62
+ end
63
+ raise ArgumentError, "staging root must be an existing absolute directory" unless root.start_with?("/") && File.directory?(root) && !File.symlink?(root)
64
+ @root = File.realpath(root)
65
+ end
66
+
67
+ def staged? = @root != "/"
68
+
69
+ def plan
70
+ if @platform == :darwin
71
+ return {"#{PREFIX}/chmod-bpf" => asset("macos/chmod-bpf", mode: 0o755),
72
+ "/Library/LaunchDaemons/org.vanken.chmod-bpf.plist" => asset("macos/org.vanken.chmod-bpf.plist")}
73
+ end
74
+ files = {"#{PREFIX}/vanken-capture" => asset("linux/vanken-capture.wrapper", mode: 0o755),
75
+ "#{PREFIX}/helper" => {contents: File.binread(File.join(SOURCE, "exe/vanken-capture")), mode: 0o755},
76
+ "/usr/share/applications/vanken.desktop" => asset("linux/vanken.desktop")}
77
+ if @policy == :polkit
78
+ files["/usr/share/polkit-1/actions/org.vanken.capture.policy"] = asset("linux/org.vanken.capture.policy")
79
+ else
80
+ files["/etc/sudoers.d/vanken"] = asset("linux/vanken.sudoers", mode: 0o440)
81
+ end
82
+ Dir.glob(File.join(SOURCE, "lib", "**", "*"), File::FNM_DOTMATCH).each do |path|
83
+ raise ArgumentError, "source library contains a symlink" if File.symlink?(path)
84
+ next if File.directory?(path)
85
+ files["#{PREFIX}/#{path.delete_prefix("#{SOURCE}/")}"] = {contents: File.binread(path), mode: 0o644}
86
+ end
87
+ files
88
+ end
89
+
90
+ def install
91
+ raise ArgumentError, "system installation requires root; use --dry-run or --root for review" unless staged? || Process.euid.zero?
92
+ raise ArgumentError, "use --root or --dry-run to prepare files for another platform" unless staged? || RUBY_PLATFORM.include?(@platform.to_s)
93
+ validate_runtime! if @platform == :linux && !staged?
94
+ validate_group! if @platform == :darwin || @policy == :sudoers
95
+ plan.each do |absolute, item|
96
+ destination = File.join(@root, absolute.delete_prefix("/"))
97
+ secure_directory!(File.dirname(destination))
98
+ safe_entry!(destination) if File.exist?(destination) || File.symlink?(destination)
99
+ Tempfile.create([".vanken-", ".tmp"], File.dirname(destination)) do |file|
100
+ file.binmode
101
+ file.write(item[:contents])
102
+ file.chmod(item[:mode])
103
+ file.flush
104
+ validate_sudoers!(file.path) if absolute == "/etc/sudoers.d/vanken" && !staged?
105
+ File.rename(file.path, destination)
106
+ end
107
+ end
108
+ self
109
+ end
110
+
111
+ def validate_runtime!
112
+ trusted_tree!(@ruby)
113
+ raise ArgumentError, "Ruby interpreter must be executable" unless File.file?(@ruby) && File.executable?(@ruby)
114
+ runtime_prefix = File.dirname(File.dirname(File.realpath(@ruby)))
115
+ trusted_tree!(runtime_prefix) unless ["/", "/usr"].include?(runtime_prefix)
116
+ paths, status = Open3.capture2({"PATH" => "/usr/bin:/bin"}, @ruby, "--disable-gems", "-e", "$LOAD_PATH.each { |path| puts path }", unsetenv_others: true, chdir: "/")
117
+ raise ArgumentError, "could not inspect the Ruby runtime" unless status.success?
118
+ paths.each_line { |path| trusted_tree!(path.strip) }
119
+ trusted_tree!(@gem_home)
120
+ raise ArgumentError, "gem home must be a directory" unless File.directory?(@gem_home)
121
+ trusted_tree!(SOURCE)
122
+ self
123
+ end
124
+
125
+ def validate_group!
126
+ Etc.getgrnam(@group)
127
+ rescue ArgumentError
128
+ raise ArgumentError, "create the #{@group} capture group and add the intended users before installing"
129
+ end
130
+
131
+ def next_steps
132
+ if @platform == :darwin
133
+ ["sudo launchctl bootstrap system /Library/LaunchDaemons/org.vanken.chmod-bpf.plist",
134
+ "Log out and log in after changing group membership, then run vanken-capture --check --interface lo0."]
135
+ else
136
+ ["Run vanken-capture --check --interface IF as a normal user, then start Vanken."]
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def asset(relative, mode: 0o644)
143
+ contents = File.binread(File.join(SOURCE, "packaging", relative)).gsub("VANKEN_GROUP", @group)
144
+ contents = contents.gsub("VANKEN_RUBY", Shellwords.escape(@ruby.to_s)).gsub("VANKEN_GEM_HOME", Shellwords.escape(@gem_home.to_s))
145
+ {contents: contents, mode: mode}
146
+ end
147
+
148
+ def trusted_tree!(path, visited = {})
149
+ resolved = trusted_path!(path)
150
+ return if visited[resolved]
151
+ visited[resolved] = true
152
+ return unless File.directory?(resolved)
153
+ Dir.children(resolved).each { |name| trusted_tree!(File.join(resolved, name), visited) }
154
+ rescue SystemCallError => error
155
+ raise ArgumentError, "cannot verify root-owned privileged path: #{error.message}"
156
+ end
157
+
158
+ def trusted_path!(path)
159
+ resolved = File.realpath(path)
160
+ current = path
161
+ loop do
162
+ stat = File.lstat(current)
163
+ unless stat.uid.zero? && (stat.symlink? || (stat.mode & 0o022).zero?) && (stat.file? || stat.directory? || stat.symlink?)
164
+ raise ArgumentError, "privileged paths must be root-owned without group or other write access"
165
+ end
166
+ if stat.symlink?
167
+ target = File.readlink(current)
168
+ trusted_path!(target.start_with?("/") ? target : File.join(File.dirname(current), target))
169
+ end
170
+ break if current == "/"
171
+ current = File.dirname(current)
172
+ end
173
+ trusted_path!(resolved) if path != resolved
174
+ resolved
175
+ end
176
+
177
+ def safe_entry!(path)
178
+ stat = File.lstat(path)
179
+ raise ArgumentError, "unsafe installation path or symlink: #{path}" unless stat.uid == Process.euid && (stat.mode & 0o022).zero? && (stat.file? || stat.directory?)
180
+ end
181
+
182
+ def secure_directory!(path)
183
+ safe_entry!(@root)
184
+ current = @root
185
+ path.delete_prefix(@root).split("/").reject(&:empty?).each do |part|
186
+ current = File.join(current, part)
187
+ Dir.mkdir(current, 0o755) unless File.exist?(current) || File.symlink?(current)
188
+ safe_entry!(current)
189
+ raise ArgumentError, "installation parent is not a directory" unless File.directory?(current)
190
+ end
191
+ end
192
+
193
+ def validate_sudoers!(path)
194
+ executable = %w[/usr/sbin/visudo /sbin/visudo].find { |candidate| File.executable?(candidate) }
195
+ raise ArgumentError, "visudo is required to install sudoers permissions" unless executable
196
+ _, errors, status = Open3.capture3(executable, "-cf", path)
197
+ raise ArgumentError, "invalid sudoers configuration: #{errors}" unless status.success?
198
+ end
199
+ end
200
+ end
201
+ end
@@ -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