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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "capture_filter"
|
|
4
|
+
require_relative "../core/display_filter/parser"
|
|
5
|
+
|
|
6
|
+
module Vanken
|
|
7
|
+
module Gateway
|
|
8
|
+
class DisplayCaptureFilter
|
|
9
|
+
Packet = Struct.new(:data, :original_length, :linktype, :meta)
|
|
10
|
+
TUNNEL_PORTS = [4789, 8472, 6081].freeze
|
|
11
|
+
attr_reader :expression
|
|
12
|
+
|
|
13
|
+
def initialize(expression)
|
|
14
|
+
@expression = convert(Core::DisplayFilter::Parser.new(expression).parse)
|
|
15
|
+
@program = CaptureFilter.compile(@expression) if @expression
|
|
16
|
+
rescue CaptureFilter::Error
|
|
17
|
+
@expression = @program = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# nil requests the ordinary VDF evaluator; false is a definite non-match.
|
|
21
|
+
def match(frame)
|
|
22
|
+
return nil unless @program && eligible?(frame)
|
|
23
|
+
@program.match?(Packet.new(frame.bytes, frame.original_length, frame.linktype, {}))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def convert(node)
|
|
29
|
+
case node.kind
|
|
30
|
+
when :and, :or
|
|
31
|
+
left, right = convert(node.left), convert(node.right)
|
|
32
|
+
"(#{left}) #{node.kind} (#{right})" if left && right
|
|
33
|
+
when :not
|
|
34
|
+
child = convert(node.left)
|
|
35
|
+
"not (#{child})" if child
|
|
36
|
+
when :test
|
|
37
|
+
field, predicate = node.left.value, node.right
|
|
38
|
+
return {"ip" => "ip", "ipv6" => "ip6", "tcp" => "tcp", "udp" => "udp"}[field] unless predicate
|
|
39
|
+
return nil unless %i[eq ne in].include?(predicate.operator)
|
|
40
|
+
members = predicate.values.map { |member| term(field, member) }
|
|
41
|
+
return nil if members.any?(&:nil?) || members.empty?
|
|
42
|
+
result = members.map { |member| "(#{member})" }.join(" or ")
|
|
43
|
+
predicate.operator == :ne ? "not (#{result})" : result
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def term(field, member)
|
|
48
|
+
token = member.first
|
|
49
|
+
if (port = /\A(tcp|udp)\.(port|srcport|dstport)\z/.match(field))
|
|
50
|
+
return nil unless token.type == :integer && token.value.between?(0, 65_535)
|
|
51
|
+
direction = {"srcport" => "src ", "dstport" => "dst "}.fetch(port[2], "")
|
|
52
|
+
if member.last
|
|
53
|
+
last = member.last
|
|
54
|
+
return nil unless last.type == :integer && last.value.between?(token.value, 65_535)
|
|
55
|
+
"#{port[1]} #{direction}portrange #{token.value}-#{last.value}"
|
|
56
|
+
else
|
|
57
|
+
"#{port[1]} #{direction}port #{token.value}"
|
|
58
|
+
end
|
|
59
|
+
elsif (address = /\A(ip|ipv6)\.(addr|src|dst)\z/.match(field))
|
|
60
|
+
return nil unless token.type == :address && !member.last
|
|
61
|
+
value = IPAddr.new(token.value)
|
|
62
|
+
return nil unless value.ipv4? == (address[1] == "ip")
|
|
63
|
+
direction = {"src" => "src ", "dst" => "dst "}.fetch(address[2], "")
|
|
64
|
+
prefix = token.value.split("/", 2)[1]
|
|
65
|
+
literal = prefix ? "net #{value}/#{prefix}" : "host #{value}"
|
|
66
|
+
"#{address[1] == 'ip' ? 'ip' : 'ip6'} #{direction}#{literal}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def eligible?(frame)
|
|
71
|
+
bytes = frame.bytes
|
|
72
|
+
return false unless frame.linktype == 1 && frame.original_length == bytes.bytesize && bytes.bytesize >= 34
|
|
73
|
+
type = bytes.unpack1("n", offset: 12)
|
|
74
|
+
if type == 0x0800
|
|
75
|
+
return false unless bytes.getbyte(14) == 0x45 && (bytes.unpack1("n", offset: 20) & 0x3fff).zero?
|
|
76
|
+
length = bytes.unpack1("n", offset: 16)
|
|
77
|
+
return false unless length >= 20 && length + 14 <= bytes.bytesize
|
|
78
|
+
transport, finish, protocol = 34, length + 14, bytes.getbyte(23)
|
|
79
|
+
elsif type == 0x86dd
|
|
80
|
+
return false unless bytes.bytesize >= 54 && bytes.getbyte(14) >> 4 == 6
|
|
81
|
+
length = bytes.unpack1("n", offset: 18)
|
|
82
|
+
return false unless length.positive? && length + 54 <= bytes.bytesize
|
|
83
|
+
transport, finish, protocol = 54, length + 54, bytes.getbyte(20)
|
|
84
|
+
else
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
if protocol == 6
|
|
88
|
+
return false unless finish - transport >= 20
|
|
89
|
+
header = (bytes.getbyte(transport + 12) >> 4) * 4
|
|
90
|
+
header >= 20 && transport + header <= finish
|
|
91
|
+
elsif protocol == 17
|
|
92
|
+
return false unless finish - transport >= 8
|
|
93
|
+
ports = bytes.unpack("n2", offset: transport)
|
|
94
|
+
length = bytes.unpack1("n", offset: transport + 4)
|
|
95
|
+
(ports & TUNNEL_PORTS).empty? && length >= 8 && transport + length <= finish
|
|
96
|
+
else
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -15,12 +15,36 @@ module Vanken
|
|
|
15
15
|
def self.rank(value) = {nil => 0, note: 1, warning: 2, warn: 2, error: 3}.fetch(value, 0)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# The public plugin DSL registers through Registry.default; keep that lookup local.
|
|
19
|
+
module RegistryScope
|
|
20
|
+
module Default
|
|
21
|
+
# @rbs () -> Redhound::Registry
|
|
22
|
+
def default = Thread.current[:vanken_registry] || super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs [T] (Redhound::Registry registry) { () -> T } -> T
|
|
26
|
+
def self.with(registry)
|
|
27
|
+
previous = Thread.current[:vanken_registry]
|
|
28
|
+
Thread.current[:vanken_registry] = registry
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
Thread.current[:vanken_registry] = previous
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
Redhound::Registry.singleton_class.prepend(RegistryScope::Default)
|
|
35
|
+
|
|
18
36
|
class Dissector
|
|
19
37
|
attr_reader :registry #: Redhound::Registry
|
|
20
38
|
# @rbs (?verify_checksums: bool, ?decode_as: Array[String], ?plugins: Array[String]) -> void
|
|
21
39
|
def initialize(verify_checksums: false, decode_as: [], plugins: [])
|
|
22
|
-
plugins.each { |path| load File.expand_path(path) }
|
|
23
40
|
@registry = Redhound::Registry.default.copy
|
|
41
|
+
RegistryScope.with(@registry) do
|
|
42
|
+
plugins.each do |path|
|
|
43
|
+
load File.expand_path(path), true
|
|
44
|
+
rescue LoadError, SyntaxError => error
|
|
45
|
+
raise Vanken::ConfigError, "failed to load plugin #{path}: #{error.message}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
24
48
|
decode_as.each { |rule| @registry.decode_as(rule) }
|
|
25
49
|
@engine = Redhound::Engine.new(registry: @registry, verify_checksums: verify_checksums)
|
|
26
50
|
end
|
|
@@ -45,6 +69,7 @@ module Vanken
|
|
|
45
69
|
|
|
46
70
|
class PacketView
|
|
47
71
|
attr_reader :raw_packet #: Redhound::Packet
|
|
72
|
+
attr_reader :registry #: Redhound::Registry
|
|
48
73
|
# @rbs (Redhound::Packet packet, Redhound::Registry registry) -> void
|
|
49
74
|
def initialize(packet, registry) = (@raw_packet = packet; @registry = registry)
|
|
50
75
|
# @rbs (String name) -> Array[untyped]
|
|
@@ -86,7 +111,7 @@ module Vanken
|
|
|
86
111
|
end
|
|
87
112
|
# @rbs () -> String
|
|
88
113
|
def info
|
|
89
|
-
safe_text(@raw_packet.summary)
|
|
114
|
+
RegistryScope.with(@registry) { safe_text(@raw_packet.summary) }
|
|
90
115
|
end
|
|
91
116
|
# @rbs () -> Core::annotation_values
|
|
92
117
|
def annotations
|
|
@@ -132,12 +157,12 @@ module Vanken
|
|
|
132
157
|
end
|
|
133
158
|
|
|
134
159
|
class FieldCatalog
|
|
135
|
-
# @rbs () -> void
|
|
136
|
-
def initialize
|
|
160
|
+
# @rbs (?registry: Redhound::Registry) -> void
|
|
161
|
+
def initialize(registry: Redhound::Registry.default)
|
|
137
162
|
@mutex = Mutex.new
|
|
138
163
|
@fields = {} #: Hash[String, Hash[Symbol, untyped]]
|
|
139
|
-
@protocols =
|
|
140
|
-
|
|
164
|
+
@protocols = registry.protocols.keys.map { |id| id == :ipv4 ? "ip" : id.to_s }
|
|
165
|
+
registry.protocols.each do |id, klass|
|
|
141
166
|
definitions = Array(klass.compiled_header&.definitions) #: Array[Redhound::FieldDefinition]
|
|
142
167
|
definitions.each do |field|
|
|
143
168
|
@fields[field.name] = {type: field.type, source: :dissect, protocol: id.to_s}.freeze
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "csv"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require_relative "file_writer"
|
|
7
|
+
require_relative "detail_builder"
|
|
8
|
+
|
|
9
|
+
module Vanken
|
|
10
|
+
module Gateway
|
|
11
|
+
class Exporter
|
|
12
|
+
DEFAULT_COLUMNS = %i[number time source destination protocol length info].map { |key| {key: key, label: key.to_s.capitalize} }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(document) = (@document = document)
|
|
15
|
+
|
|
16
|
+
def numbers(scope: :all, range: "", selected: nil, exclude_ignored: false)
|
|
17
|
+
limit = @document.count
|
|
18
|
+
empty = [] #: Array[Integer]
|
|
19
|
+
values = case scope.to_sym
|
|
20
|
+
when :all then (1..limit).to_a
|
|
21
|
+
when :displayed then @document.display_numbers
|
|
22
|
+
when :selected then selected ? [selected] : empty
|
|
23
|
+
when :marked then @document.marked.to_a
|
|
24
|
+
when :between_marks
|
|
25
|
+
marks = @document.marked.to_a.sort
|
|
26
|
+
marks.empty? ? empty : (marks.first..marks.last).to_a
|
|
27
|
+
when :range then ranges(range, limit)
|
|
28
|
+
else raise ArgumentError, "invalid packet scope"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, "invalid packet number" unless values.all? { |number| number.is_a?(Integer) && number.between?(1, limit) }
|
|
31
|
+
values = values.reject { |number| @document.ignored.include?(number) } if exclude_ignored
|
|
32
|
+
values.sort.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write(path, format:, numbers: nil, columns: DEFAULT_COLUMNS, cancelled: nil)
|
|
36
|
+
format = format.to_sym
|
|
37
|
+
raise ArgumentError, "invalid export format" unless %i[pcap pcapng json ndjson text csv].include?(format)
|
|
38
|
+
numbers = (numbers || self.numbers).to_a.sort.uniq
|
|
39
|
+
raise IndexError, "invalid packet number" unless numbers.all? { |number| number.is_a?(Integer) && number.between?(1, @document.count) }
|
|
40
|
+
file = Tempfile.create([".vanken-export-", ".tmp"], File.dirname(File.expand_path(path)))
|
|
41
|
+
begin
|
|
42
|
+
file.binmode
|
|
43
|
+
case format
|
|
44
|
+
when :pcap, :pcapng
|
|
45
|
+
linktype = numbers.empty? ? 1 : @document.store.metadata(numbers.first)[:linktype]
|
|
46
|
+
FileWriter.open(file, format: format, linktype: linktype) do |writer|
|
|
47
|
+
numbers.each { |number| check_cancelled(cancelled); writer << @document.store.read(number) }
|
|
48
|
+
end
|
|
49
|
+
when :json, :ndjson then json(file, numbers, format, cancelled)
|
|
50
|
+
when :text
|
|
51
|
+
numbers.each do |number|
|
|
52
|
+
check_cancelled(cancelled)
|
|
53
|
+
@document.details(number).each { |node| text_node(file, node, 0) }
|
|
54
|
+
file.write("\n")
|
|
55
|
+
end
|
|
56
|
+
when :csv
|
|
57
|
+
csv = CSV.new(file)
|
|
58
|
+
visible = columns.reject { |column| column[:visible] == false }
|
|
59
|
+
csv << visible.map { |column| column[:label] || column[:key].to_s }
|
|
60
|
+
numbers.each { |number| check_cancelled(cancelled); csv << visible.map { |column| csv_value(number, column) } }
|
|
61
|
+
end
|
|
62
|
+
file.flush
|
|
63
|
+
file.fsync
|
|
64
|
+
file.close
|
|
65
|
+
File.rename(file.path, File.expand_path(path))
|
|
66
|
+
ensure
|
|
67
|
+
file.close unless file.closed?
|
|
68
|
+
FileUtils.rm_f(file.path)
|
|
69
|
+
end
|
|
70
|
+
path
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def ranges(value, limit)
|
|
76
|
+
raise ArgumentError, "packet range is empty" if value.strip.empty?
|
|
77
|
+
value.split(",", -1).flat_map do |part|
|
|
78
|
+
match = /\A\s*(\d+)(?:\s*-\s*(\d*))?\s*\z/.match(part)
|
|
79
|
+
raise ArgumentError, "invalid packet range: #{part}" unless match
|
|
80
|
+
first = match[1].to_i
|
|
81
|
+
last = match[2].nil? ? first : match[2].empty? ? limit : match[2].to_i
|
|
82
|
+
raise ArgumentError, "packet range is outside capture" unless first.between?(1, limit) && last.between?(first, limit)
|
|
83
|
+
(first..last).to_a
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_cancelled(cancelled)
|
|
88
|
+
raise Vanken::Error, "operation cancelled" if cancelled&.call || @document.closing?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def json(file, numbers, format, cancelled)
|
|
92
|
+
dissector = Dissector.new(**@document.analysis_gateway_options)
|
|
93
|
+
analysis = Analysis.new(registry: dissector.registry, **@document.analysis_options)
|
|
94
|
+
selected = numbers.to_set
|
|
95
|
+
written = false
|
|
96
|
+
file.write("[") if format == :json
|
|
97
|
+
# Replay preceding packets so stateful fields and reassembled layers match the document.
|
|
98
|
+
(1..(numbers.last || 0)).each do |number|
|
|
99
|
+
check_cancelled(cancelled)
|
|
100
|
+
packet = dissector.dissect(@document.store.read(number))
|
|
101
|
+
analysis.update(packet)
|
|
102
|
+
next unless selected.include?(number)
|
|
103
|
+
file.write(",") if format == :json && written
|
|
104
|
+
file.write(JSON.generate(packet.to_h))
|
|
105
|
+
file.write("\n") if format == :ndjson
|
|
106
|
+
written = true
|
|
107
|
+
end
|
|
108
|
+
file.write("]\n") if format == :json
|
|
109
|
+
ensure
|
|
110
|
+
analysis&.close
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def text_node(file, node, depth)
|
|
114
|
+
file.write("#{' ' * depth}#{node.label}\n")
|
|
115
|
+
node.children.each { |child| text_node(file, child, depth + 1) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def csv_value(number, column)
|
|
119
|
+
return @document.view(number).values(column[:field]).map(&:to_s).join(", ") if column[:field]
|
|
120
|
+
return @document.time_value(number, column[:format] || :relative) if column[:key].to_sym == :time
|
|
121
|
+
@document.row(number)[column[:key].to_sym]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -22,12 +22,34 @@ module Vanken
|
|
|
22
22
|
def initialize(output, format: :pcapng, **options)
|
|
23
23
|
@format = format.to_sym
|
|
24
24
|
@interfaces = {} #: Hash[Hash[String, untyped], Redhound::Capture::Interface]
|
|
25
|
-
|
|
25
|
+
rotating = options[:max_bytes] || options[:interval]
|
|
26
|
+
if rotating
|
|
27
|
+
raise ArgumentError, "ring output must be a fixed file path" unless output.is_a?(String) && output != "-" && !output.include?("%")
|
|
28
|
+
count = options.fetch(:file_count, 10)
|
|
29
|
+
raise ArgumentError, "ring file count must be between 1 and 1000" unless count.is_a?(Integer) && count.between?(1, 1000)
|
|
30
|
+
%i[max_bytes interval].each do |key|
|
|
31
|
+
value = options[key]
|
|
32
|
+
raise ArgumentError, "#{key} must be positive" if value && (!value.is_a?(Numeric) || !value.finite? || !value.positive?)
|
|
33
|
+
end
|
|
34
|
+
options = options.merge(file_count: count)
|
|
35
|
+
# The public Writer cycles slots when max_bytes is present, also for time-based rings.
|
|
36
|
+
options[:max_bytes] ||= 9_223_372_036_854_775_807
|
|
37
|
+
extension = File.extname(output)
|
|
38
|
+
stem = output.delete_suffix(extension)
|
|
39
|
+
count.times do |index|
|
|
40
|
+
path = "#{stem}_#{format('%05d', index)}#{extension}"
|
|
41
|
+
next unless File.exist?(path) || File.symlink?(path)
|
|
42
|
+
File.open(path, File::RDWR | File::NOFOLLOW) do |file|
|
|
43
|
+
raise ArgumentError, "ring output must be a regular, unlinked file owned by this user" unless file.stat.file? && file.stat.nlink == 1 && file.stat.uid == Process.uid
|
|
44
|
+
file.chmod(0o600)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
elsif output.is_a?(String)
|
|
26
48
|
@owned = File.open(output, "wb", 0o600)
|
|
27
49
|
@owned.chmod(0o600)
|
|
28
50
|
end
|
|
29
51
|
@writer = Redhound::Writer.open(@owned || output, format: @format, **options)
|
|
30
|
-
rescue Redhound::Error, IOError, SystemCallError => error
|
|
52
|
+
rescue Redhound::Error, ArgumentError, IOError, SystemCallError => error
|
|
31
53
|
@owned&.close
|
|
32
54
|
raise Vanken::FileError, error.message
|
|
33
55
|
end
|
|
@@ -45,6 +67,8 @@ module Vanken
|
|
|
45
67
|
end
|
|
46
68
|
# @rbs () -> void
|
|
47
69
|
def flush = @writer.flush
|
|
70
|
+
# @rbs () -> Integer
|
|
71
|
+
def bytes_written = @writer.bytes_written
|
|
48
72
|
# @rbs (Redhound::Capture::Stats | Hash[Symbol | String, Integer] stats, ?interface: Redhound::Capture::Interface?) -> void
|
|
49
73
|
def write_stats(stats, interface: nil)
|
|
50
74
|
if stats.is_a?(Hash)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "redhound"
|
|
4
|
+
require "open3"
|
|
4
5
|
|
|
5
6
|
module Vanken
|
|
6
7
|
module Gateway
|
|
@@ -25,6 +26,64 @@ module Vanken
|
|
|
25
26
|
def self.find(name)
|
|
26
27
|
list.find { |interface| interface[:name] == name } || raise(NotFound, "interface #{name.inspect} not found")
|
|
27
28
|
end
|
|
29
|
+
|
|
30
|
+
# @rbs () -> Hash[String, Integer]
|
|
31
|
+
def self.traffic_counters
|
|
32
|
+
if RUBY_PLATFORM.include?("linux")
|
|
33
|
+
parse_linux_counters(File.read("/proc/net/dev"))
|
|
34
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
35
|
+
output, _, status = Open3.capture3("/usr/sbin/netstat", "-ibn")
|
|
36
|
+
status.success? ? parse_darwin_counters(output) : {}
|
|
37
|
+
else
|
|
38
|
+
{}
|
|
39
|
+
end
|
|
40
|
+
rescue SystemCallError, IOError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @rbs (String text) -> Hash[String, Integer]
|
|
45
|
+
def self.parse_linux_counters(text)
|
|
46
|
+
result = {} #: Hash[String, Integer]
|
|
47
|
+
text.each_line do |line|
|
|
48
|
+
name, fields = line.split(":", 2)
|
|
49
|
+
next unless name && fields
|
|
50
|
+
values = fields.split
|
|
51
|
+
next unless values.length >= 16 && values[0].match?(/\A\d+\z/) && values[8].match?(/\A\d+\z/)
|
|
52
|
+
result[name.strip] = values[0].to_i + values[8].to_i
|
|
53
|
+
end
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @rbs (String text) -> Hash[String, Integer]
|
|
58
|
+
def self.parse_darwin_counters(text)
|
|
59
|
+
lines = text.lines
|
|
60
|
+
header = lines.shift.to_s.split
|
|
61
|
+
first, received, sent = header.index("Ipkts"), header.index("Ibytes"), header.index("Obytes")
|
|
62
|
+
return {} unless first && received && sent
|
|
63
|
+
result = {} #: Hash[String, Integer]
|
|
64
|
+
lines.each do |line|
|
|
65
|
+
fields = line.split
|
|
66
|
+
next unless fields[2]&.start_with?("<Link#")
|
|
67
|
+
counters = fields.last(header.length - first)
|
|
68
|
+
incoming, outgoing = counters[received - first], counters[sent - first]
|
|
69
|
+
next unless incoming&.match?(/\A\d+\z/) && outgoing&.match?(/\A\d+\z/)
|
|
70
|
+
result[fields.first.delete_suffix("*")] = incoming.to_i + outgoing.to_i
|
|
71
|
+
end
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @rbs (?previous: Hash[Symbol, untyped]?, ?history: Hash[String, Array[Integer]], ?now: Integer | Float) -> Hash[Symbol, untyped]
|
|
76
|
+
def self.sample_traffic(previous: nil, history: {}, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
77
|
+
counters = traffic_counters
|
|
78
|
+
elapsed = previous ? now.to_f - previous[:time].to_f : 0.0
|
|
79
|
+
rates = counters.to_h do |name, bytes|
|
|
80
|
+
old = previous && previous[:counters][name]
|
|
81
|
+
rate = old && elapsed.positive? ? [(bytes - old).fdiv(elapsed), 0].max.to_i : 0
|
|
82
|
+
[name, rate]
|
|
83
|
+
end
|
|
84
|
+
samples = rates.to_h { |name, rate| [name, ((history[name] || []) + [rate]).last(60)] }
|
|
85
|
+
{previous: {time: now, counters: counters}, rates: rates, history: samples}
|
|
86
|
+
end
|
|
28
87
|
end
|
|
29
88
|
end
|
|
30
89
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "resolv"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module Vanken
|
|
9
|
+
module Gateway
|
|
10
|
+
# Reverse DNS never runs on the requesting thread. Callbacks run on the
|
|
11
|
+
# worker; UI callers must post their publication to the UI executor.
|
|
12
|
+
class Resolver
|
|
13
|
+
NEGATIVE_TTL = 30.0
|
|
14
|
+
|
|
15
|
+
# @rbs @timeout: Float
|
|
16
|
+
# @rbs @limit: Integer
|
|
17
|
+
# @rbs @resolver: untyped
|
|
18
|
+
# @rbs @queue: SizedQueue[String?]
|
|
19
|
+
# @rbs @cache: Hash[String, [String?, Float?]]
|
|
20
|
+
# @rbs @pending: Hash[String, Array[^(String) -> void]]
|
|
21
|
+
# @rbs @mutex: Mutex
|
|
22
|
+
# @rbs @closed: bool
|
|
23
|
+
# @rbs @worker: Thread
|
|
24
|
+
|
|
25
|
+
# @rbs (?timeout: Integer | Float, ?limit: Integer, ?resolver: untyped) -> void
|
|
26
|
+
def initialize(timeout: 1.0, limit: 256, resolver: nil)
|
|
27
|
+
unless timeout.is_a?(Numeric) && timeout.to_f.finite? && timeout.positive?
|
|
28
|
+
raise ArgumentError, "timeout must be finite and positive"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, "limit must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
|
|
31
|
+
@timeout, @limit = timeout.to_f, limit
|
|
32
|
+
@resolver = resolver || Resolv::DNS.new
|
|
33
|
+
@resolver.timeouts = [@timeout] if @resolver.respond_to?(:timeouts=)
|
|
34
|
+
@queue = SizedQueue.new(@limit) #: SizedQueue[String?]
|
|
35
|
+
@cache = {} #: Hash[String, [String?, Float?]]
|
|
36
|
+
@pending = {} #: Hash[String, Array[^(String) -> void]]
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@closed = false
|
|
39
|
+
@worker = Thread.new { work }
|
|
40
|
+
@worker.report_on_exception = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs (String address) ?{ (String) -> void } -> String
|
|
44
|
+
def request(address, &callback)
|
|
45
|
+
key = numeric_address(address)
|
|
46
|
+
return address unless key
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return address if @closed
|
|
49
|
+
entry = @cache.delete(key)
|
|
50
|
+
if entry && (!entry[1] || entry[1] > now)
|
|
51
|
+
@cache[key] = entry
|
|
52
|
+
return entry[0] || address
|
|
53
|
+
end
|
|
54
|
+
if (waiting = @pending[key])
|
|
55
|
+
waiting << callback if callback
|
|
56
|
+
else
|
|
57
|
+
begin
|
|
58
|
+
@queue.push(key, true)
|
|
59
|
+
@pending[key] = callback ? [callback] : []
|
|
60
|
+
rescue ThreadError
|
|
61
|
+
# A future viewport request may retry after capacity is available.
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
address
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @rbs () -> void
|
|
69
|
+
def close
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
return if @closed
|
|
72
|
+
@closed = true
|
|
73
|
+
@pending.clear
|
|
74
|
+
@queue.clear
|
|
75
|
+
@queue << nil
|
|
76
|
+
end
|
|
77
|
+
@worker.join(@timeout + 0.1) unless Thread.current == @worker
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# @rbs (String address) -> String?
|
|
84
|
+
def numeric_address(address)
|
|
85
|
+
return nil if address.include?("/")
|
|
86
|
+
IPAddr.new(address).to_s
|
|
87
|
+
rescue IPAddr::Error
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @rbs () -> Float
|
|
92
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
93
|
+
|
|
94
|
+
# @rbs (String address) -> String?
|
|
95
|
+
def lookup(address)
|
|
96
|
+
name = Timeout.timeout(@timeout) { @resolver.getname(address) }
|
|
97
|
+
name.to_s.dup.freeze unless name.nil? || name.to_s.empty?
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @rbs () -> void
|
|
103
|
+
def work
|
|
104
|
+
while (address = @queue.pop)
|
|
105
|
+
name = lookup(address)
|
|
106
|
+
callbacks = @mutex.synchronize do
|
|
107
|
+
return if @closed
|
|
108
|
+
@cache[address] = [name, name ? nil : now + NEGATIVE_TTL]
|
|
109
|
+
@cache.shift while @cache.size > @limit
|
|
110
|
+
@pending.delete(address) || []
|
|
111
|
+
end
|
|
112
|
+
next unless name
|
|
113
|
+
callbacks.each do |callback|
|
|
114
|
+
break if @mutex.synchronize { @closed }
|
|
115
|
+
begin
|
|
116
|
+
callback.call(name)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
# One consumer must not prevent other consumers or DNS requests.
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
ensure
|
|
123
|
+
begin
|
|
124
|
+
@resolver.close if @resolver.respond_to?(:close)
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require_relative "dissector"
|
|
5
|
+
|
|
6
|
+
module Vanken
|
|
7
|
+
module Gateway
|
|
8
|
+
HierarchyRow = Data.define(
|
|
9
|
+
:path, #: Array[Symbol]
|
|
10
|
+
:packets, #: Integer
|
|
11
|
+
:bytes #: Integer
|
|
12
|
+
)
|
|
13
|
+
ConversationRow = Data.define(
|
|
14
|
+
:addr_a, #: String
|
|
15
|
+
:port_a, #: Integer
|
|
16
|
+
:addr_b, #: String
|
|
17
|
+
:port_b, #: Integer
|
|
18
|
+
:packets_ab, #: Integer
|
|
19
|
+
:packets_ba, #: Integer
|
|
20
|
+
:bytes_ab, #: Integer
|
|
21
|
+
:bytes_ba, #: Integer
|
|
22
|
+
:start_ns, #: Integer
|
|
23
|
+
:end_ns, #: Integer
|
|
24
|
+
:duration_ns #: Integer
|
|
25
|
+
)
|
|
26
|
+
EndpointRow = Data.define(
|
|
27
|
+
:address, #: String
|
|
28
|
+
:port, #: Integer
|
|
29
|
+
:packets_tx, #: Integer
|
|
30
|
+
:packets_rx, #: Integer
|
|
31
|
+
:bytes_tx, #: Integer
|
|
32
|
+
:bytes_rx #: Integer
|
|
33
|
+
)
|
|
34
|
+
StatisticsTable = Data.define(
|
|
35
|
+
:kind, #: Symbol
|
|
36
|
+
:type, #: Symbol?
|
|
37
|
+
:rows, #: Array[HierarchyRow | ConversationRow | EndpointRow]
|
|
38
|
+
:evicted #: Integer
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# The upstream session and its private table schema stay inside the Gateway.
|
|
42
|
+
class Statistics
|
|
43
|
+
# @rbs (?specs: Array[String], ?registry: Redhound::Registry, **untyped options) -> void
|
|
44
|
+
def initialize(specs: ["phs"], registry: Redhound::Registry.default, **options)
|
|
45
|
+
@session = Redhound::Analysis::Session.new(stats: specs, registry: registry, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @rbs (PacketView packet) -> void
|
|
49
|
+
def update(packet) = @session.update(packet.raw_packet)
|
|
50
|
+
|
|
51
|
+
# @rbs () -> Array[StatisticsTable]
|
|
52
|
+
def tables
|
|
53
|
+
@session.statistics.map do |table|
|
|
54
|
+
value = table.to_h
|
|
55
|
+
rows = value[:rows].map do |row|
|
|
56
|
+
fields = row.dup #: Hash[Symbol, untyped]
|
|
57
|
+
fields[:path] = fields[:path].dup.freeze if fields[:path]
|
|
58
|
+
fields.each_value { |item| item.freeze if item.is_a?(String) }
|
|
59
|
+
case value[:kind]
|
|
60
|
+
when :phs
|
|
61
|
+
HierarchyRow.new(path: fields.fetch(:path), packets: fields.fetch(:packets), bytes: fields.fetch(:bytes))
|
|
62
|
+
when :conv
|
|
63
|
+
ConversationRow.new(addr_a: fields.fetch(:addr_a), port_a: fields.fetch(:port_a),
|
|
64
|
+
addr_b: fields.fetch(:addr_b), port_b: fields.fetch(:port_b),
|
|
65
|
+
packets_ab: fields.fetch(:packets_ab), packets_ba: fields.fetch(:packets_ba),
|
|
66
|
+
bytes_ab: fields.fetch(:bytes_ab), bytes_ba: fields.fetch(:bytes_ba),
|
|
67
|
+
start_ns: fields.fetch(:start_ns), end_ns: fields.fetch(:end_ns), duration_ns: fields.fetch(:duration_ns))
|
|
68
|
+
when :endpoints
|
|
69
|
+
EndpointRow.new(address: fields.fetch(:address), port: fields.fetch(:port),
|
|
70
|
+
packets_tx: fields.fetch(:packets_tx), packets_rx: fields.fetch(:packets_rx),
|
|
71
|
+
bytes_tx: fields.fetch(:bytes_tx), bytes_rx: fields.fetch(:bytes_rx))
|
|
72
|
+
else raise Vanken::Error, "invalid upstream statistics table"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
StatisticsTable.new(kind: value[:kind], type: value[:type], rows: rows.freeze, evicted: table.evicted)
|
|
76
|
+
end.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @rbs () -> void
|
|
80
|
+
def close = @session.finish(StringIO.new, StringIO.new)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|