vanken 0.1.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 (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +84 -0
  5. data/docs/filters.md +25 -0
  6. data/docs/performance.md +113 -0
  7. data/docs/releases.md +15 -0
  8. data/docs/upstream.md +32 -0
  9. data/exe/vanken +5 -0
  10. data/exe/vanken-capture +6 -0
  11. data/lib/vanken/app/capture_controller.rb +197 -0
  12. data/lib/vanken/app/document.rb +305 -0
  13. data/lib/vanken/app/document_jobs.rb +199 -0
  14. data/lib/vanken/capture/analyzer.rb +44 -0
  15. data/lib/vanken/capture/analyzer_process.rb +91 -0
  16. data/lib/vanken/capture/analyzer_wire.rb +42 -0
  17. data/lib/vanken/capture/analyzer_worker.rb +153 -0
  18. data/lib/vanken/capture/control_protocol.rb +32 -0
  19. data/lib/vanken/capture/filter_worker.rb +180 -0
  20. data/lib/vanken/capture/helper_main.rb +137 -0
  21. data/lib/vanken/capture/helper_options.rb +70 -0
  22. data/lib/vanken/capture/launcher.rb +181 -0
  23. data/lib/vanken/capture/privileges.rb +24 -0
  24. data/lib/vanken/capture/receiver.rb +39 -0
  25. data/lib/vanken/cli.rb +68 -0
  26. data/lib/vanken/config/log.rb +19 -0
  27. data/lib/vanken/config/preferences.rb +126 -0
  28. data/lib/vanken/core/display_filter/compiler.rb +108 -0
  29. data/lib/vanken/core/display_filter/errors.rb +19 -0
  30. data/lib/vanken/core/display_filter/field_resolver.rb +86 -0
  31. data/lib/vanken/core/display_filter/lexer.rb +124 -0
  32. data/lib/vanken/core/display_filter/parser.rb +157 -0
  33. data/lib/vanken/core/display_filter/types.rb +176 -0
  34. data/lib/vanken/core/frame.rb +33 -0
  35. data/lib/vanken/core/frame_store.rb +179 -0
  36. data/lib/vanken/core/frame_view.rb +68 -0
  37. data/lib/vanken/core/stores.rb +192 -0
  38. data/lib/vanken/errors.rb +9 -0
  39. data/lib/vanken/gateway/capture_filter.rb +30 -0
  40. data/lib/vanken/gateway/detail_builder.rb +63 -0
  41. data/lib/vanken/gateway/dissector.rb +176 -0
  42. data/lib/vanken/gateway/file_reader.rb +153 -0
  43. data/lib/vanken/gateway/file_writer.rb +64 -0
  44. data/lib/vanken/gateway/interfaces.rb +30 -0
  45. data/lib/vanken/gateway/live_capture.rb +79 -0
  46. data/lib/vanken/gateway/packet_snapshot.rb +89 -0
  47. data/lib/vanken/ui/actions.rb +50 -0
  48. data/lib/vanken/ui/application.rb +205 -0
  49. data/lib/vanken/ui/dialogs.rb +89 -0
  50. data/lib/vanken/ui/file_operations.rb +113 -0
  51. data/lib/vanken/ui/filter_operations.rb +120 -0
  52. data/lib/vanken/ui/main_view.rb +113 -0
  53. data/lib/vanken/ui/packet_source.rb +161 -0
  54. data/lib/vanken/ui/selection.rb +91 -0
  55. data/lib/vanken/version.rb +5 -0
  56. data/lib/vanken.rb +17 -0
  57. data/packaging/README.md +33 -0
  58. data/sig/generated/vanken/app/capture_controller.rbs +58 -0
  59. data/sig/generated/vanken/app/document.rbs +117 -0
  60. data/sig/generated/vanken/app/document_jobs.rbs +27 -0
  61. data/sig/generated/vanken/capture/analyzer.rbs +11 -0
  62. data/sig/generated/vanken/capture/analyzer_process.rbs +17 -0
  63. data/sig/generated/vanken/capture/analyzer_wire.rbs +19 -0
  64. data/sig/generated/vanken/capture/analyzer_worker.rbs +45 -0
  65. data/sig/generated/vanken/capture/control_protocol.rbs +17 -0
  66. data/sig/generated/vanken/capture/filter_worker.rbs +51 -0
  67. data/sig/generated/vanken/capture/helper_main.rbs +23 -0
  68. data/sig/generated/vanken/capture/helper_options.rbs +27 -0
  69. data/sig/generated/vanken/capture/launcher.rbs +60 -0
  70. data/sig/generated/vanken/capture/privileges.rbs +12 -0
  71. data/sig/generated/vanken/capture/receiver.rbs +15 -0
  72. data/sig/generated/vanken/cli.rbs +9 -0
  73. data/sig/generated/vanken/config/log.rbs +9 -0
  74. data/sig/generated/vanken/config/preferences.rbs +49 -0
  75. data/sig/generated/vanken/core/display_filter/compiler.rbs +45 -0
  76. data/sig/generated/vanken/core/display_filter/errors.rbs +16 -0
  77. data/sig/generated/vanken/core/display_filter/field_resolver.rbs +48 -0
  78. data/sig/generated/vanken/core/display_filter/lexer.rbs +49 -0
  79. data/sig/generated/vanken/core/display_filter/parser.rbs +95 -0
  80. data/sig/generated/vanken/core/display_filter/types.rbs +45 -0
  81. data/sig/generated/vanken/core/frame.rbs +63 -0
  82. data/sig/generated/vanken/core/frame_store.rbs +65 -0
  83. data/sig/generated/vanken/core/frame_view.rbs +36 -0
  84. data/sig/generated/vanken/core/stores.rbs +110 -0
  85. data/sig/generated/vanken/errors.rbs +18 -0
  86. data/sig/generated/vanken/gateway/capture_filter.rbs +17 -0
  87. data/sig/generated/vanken/gateway/detail_builder.rbs +21 -0
  88. data/sig/generated/vanken/gateway/dissector.rbs +113 -0
  89. data/sig/generated/vanken/gateway/file_reader.rbs +53 -0
  90. data/sig/generated/vanken/gateway/file_writer.rbs +27 -0
  91. data/sig/generated/vanken/gateway/interfaces.rbs +16 -0
  92. data/sig/generated/vanken/gateway/live_capture.rbs +48 -0
  93. data/sig/generated/vanken/gateway/packet_snapshot.rbs +48 -0
  94. data/sig/generated/vanken/ui/actions.rbs +9 -0
  95. data/sig/generated/vanken/ui/application.rbs +85 -0
  96. data/sig/generated/vanken/ui/dialogs.rbs +21 -0
  97. data/sig/generated/vanken/ui/file_operations.rbs +25 -0
  98. data/sig/generated/vanken/ui/filter_operations.rbs +33 -0
  99. data/sig/generated/vanken/ui/main_view.rbs +21 -0
  100. data/sig/generated/vanken/ui/packet_source.rbs +40 -0
  101. data/sig/generated/vanken/ui/selection.rbs +23 -0
  102. data/sig/generated/vanken/version.rbs +5 -0
  103. data/sig/vanken.rbs +25 -0
  104. metadata +192 -0
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "redhound"
5
+ require "stringio"
6
+ require "io/wait"
7
+
8
+ module Vanken
9
+ module Gateway
10
+ class FileReader
11
+ include Enumerable #[Core::Frame]
12
+ QUEUED_BYTE_LIMIT = 64 << 20 #: Integer
13
+ class BufferedInput
14
+ # @rbs (IO input) -> void
15
+ def initialize(input)
16
+ @input, @buffer, @stopped = input, StringIO.new("".b), false
17
+ @input.binmode
18
+ end
19
+
20
+ # @rbs (Integer length) -> String?
21
+ def read(length)
22
+ return "".b if length.zero?
23
+ until @stopped
24
+ part = @buffer.read(length)
25
+ return part if part && !part.empty?
26
+
27
+ part = @input.read_nonblock(65_536, exception: false)
28
+ case part
29
+ when String then @buffer.string = part
30
+ when :wait_readable then @input.wait_readable(0.05)
31
+ else return nil
32
+ end
33
+ end
34
+ nil
35
+ end
36
+
37
+ # @rbs () -> void
38
+ def stop = (@stopped = true)
39
+ end
40
+ private_constant :BufferedInput, :QUEUED_BYTE_LIMIT
41
+
42
+ # @rbs (String | IO | StringIO input) -> void
43
+ def initialize(input)
44
+ io = input == "-" ? $stdin : input
45
+ @buffered_input = nil #: BufferedInput?
46
+ @packets = nil #: Thread::SizedQueue[Redhound::Packet | StandardError]?
47
+ @pump = nil #: Thread?
48
+ @budget_mutex, @budget_available, @queued_bytes = Mutex.new, ConditionVariable.new, 0
49
+ @stopped = false
50
+ @buffered_input = BufferedInput.new(io) if io.is_a?(IO) && !io.stat.file?
51
+ @reader = Redhound.open(@buffered_input || input)
52
+ @number = 0
53
+ if @buffered_input
54
+ # One parsed packet waiting for the byte budget can retain at most another 16 MiB.
55
+ @packets = SizedQueue.new(256)
56
+ @pump = Thread.new { pump_packets }
57
+ end
58
+ rescue Redhound::Error, IOError, SystemCallError => error
59
+ raise Vanken::FileError, error.message
60
+ end
61
+ # @rbs (?timeout: Numeric?) -> Core::Frame?
62
+ def next_frame(timeout: nil)
63
+ return nil if @stopped
64
+ raise ArgumentError, "timeout must be nonnegative" if timeout && timeout.negative?
65
+
66
+ packet = @packets ? @packets.pop(timeout: timeout && Float(timeout)) : @reader.next_packet(timeout: timeout)
67
+ raise packet if packet.is_a?(StandardError)
68
+ return nil unless packet
69
+ if @packets
70
+ @budget_mutex.synchronize do
71
+ @queued_bytes -= packet.data.bytesize
72
+ @budget_available.signal
73
+ end
74
+ end
75
+ @number += 1
76
+ interface = packet.interface && %i[name index linktype snaplen flags mtu mac description filter].to_h { |key| [key.to_s, packet.interface.public_send(key)] }
77
+ # Omit absent optional values; keep the declared interface metadata for saving.
78
+ interface&.compact!
79
+ Core::Frame.new(bytes: packet.data, timestamp_ns: packet.timestamp_ns, original_length: packet.original_length,
80
+ linktype: packet.linktype, interface: interface, direction: packet.direction, number: @number)
81
+ rescue Redhound::Error, IOError, SystemCallError => error
82
+ raise Vanken::FileError, error.message
83
+ end
84
+ # @rbs () { (Core::Frame) -> void } -> void
85
+ # @rbs () -> Enumerator[Core::Frame, void]
86
+ def each
87
+ return enum_for(:each) unless block_given?
88
+ loop do
89
+ packet = next_frame(timeout: 0.1)
90
+ yield packet if packet
91
+ break if eof?
92
+ end
93
+ end
94
+ # @rbs () -> bool
95
+ def eof? = @stopped || (@packets ? @packets.closed? && @packets.empty? : @reader.stopped?)
96
+ # @rbs () -> void
97
+ def stop
98
+ @budget_mutex.synchronize do
99
+ @stopped = true
100
+ @budget_available.broadcast
101
+ end
102
+ @buffered_input&.stop
103
+ @packets&.close
104
+ @reader.stop
105
+ @pump&.join
106
+ end
107
+ # @rbs () -> void
108
+ def close
109
+ stop
110
+ @reader.close
111
+ end
112
+ # @rbs () -> Redhound::Capture::Stats
113
+ def stats = @reader.stats
114
+
115
+ private
116
+
117
+ # @rbs () -> void
118
+ def pump_packets
119
+ queue = @packets
120
+ return unless queue
121
+ until @stopped
122
+ packet = @reader.next_packet
123
+ break unless packet
124
+ break unless reserve_bytes(packet)
125
+
126
+ queue << packet
127
+ end
128
+ rescue ClosedQueueError
129
+ nil
130
+ rescue StandardError => error
131
+ begin
132
+ queue << error unless @stopped
133
+ rescue ClosedQueueError
134
+ nil
135
+ end
136
+ ensure
137
+ queue&.close
138
+ end
139
+
140
+ # @rbs (Redhound::Packet packet) -> bool
141
+ def reserve_bytes(packet)
142
+ size = packet.data.bytesize
143
+ @budget_mutex.synchronize do
144
+ @budget_available.wait(@budget_mutex) until @stopped || @queued_bytes + size <= QUEUED_BYTE_LIMIT
145
+ return false if @stopped
146
+
147
+ @queued_bytes += size
148
+ end
149
+ true
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "redhound"
5
+ require_relative "../errors"
6
+
7
+ module Vanken
8
+ module Gateway
9
+ class FileWriter
10
+ # @rbs (String | IO output, **untyped options) -> FileWriter
11
+ # @rbs [T] (String | IO output, **untyped options) { (FileWriter) -> T } -> T
12
+ def self.open(output, **options)
13
+ writer = new(output, **options)
14
+ return writer unless block_given?
15
+ begin
16
+ yield writer
17
+ ensure
18
+ writer.close
19
+ end
20
+ end
21
+ # @rbs (String | IO output, ?format: Symbol | String, **untyped options) -> void
22
+ def initialize(output, format: :pcapng, **options)
23
+ @format = format.to_sym
24
+ @interfaces = {} #: Hash[Hash[String, untyped], Redhound::Capture::Interface]
25
+ if output.is_a?(String)
26
+ @owned = File.open(output, "wb", 0o600)
27
+ @owned.chmod(0o600)
28
+ end
29
+ @writer = Redhound::Writer.open(@owned || output, format: @format, **options)
30
+ rescue Redhound::Error, IOError, SystemCallError => error
31
+ @owned&.close
32
+ raise Vanken::FileError, error.message
33
+ end
34
+ # @rbs (Core::Frame | Redhound::Packet frame) -> self
35
+ def <<(frame)
36
+ packet = defined?(Core::Frame) && frame.is_a?(Core::Frame) ? Dissector.packet(frame, interfaces: @interfaces) : frame
37
+ if @format == :pcap
38
+ @linktype ||= packet.linktype
39
+ raise Vanken::FileError, "pcap requires one link type; save as pcapng" unless packet.linktype == @linktype
40
+ end
41
+ @writer << packet
42
+ self
43
+ rescue Redhound::Error, ArgumentError, IOError, SystemCallError => error
44
+ raise Vanken::FileError, error.message
45
+ end
46
+ # @rbs () -> void
47
+ def flush = @writer.flush
48
+ # @rbs (Redhound::Capture::Stats | Hash[Symbol | String, Integer] stats, ?interface: Redhound::Capture::Interface?) -> void
49
+ def write_stats(stats, interface: nil)
50
+ if stats.is_a?(Hash)
51
+ values = stats.transform_keys(&:to_sym)
52
+ stats = Redhound::Capture::Stats.new(received: values.fetch(:received, 0), dropped: values.fetch(:dropped, 0), if_dropped: values.fetch(:if_dropped, 0))
53
+ end
54
+ @writer.write_stats(stats, interface: interface)
55
+ end
56
+ # @rbs () -> void
57
+ def close
58
+ @writer&.close
59
+ ensure
60
+ @owned&.close unless @owned&.closed?
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redhound"
4
+
5
+ module Vanken
6
+ module Gateway
7
+ module Interfaces
8
+ class NotFound < StandardError; end
9
+
10
+ # @rbs () -> Array[Hash[Symbol, untyped]]
11
+ def self.list
12
+ result = Redhound::Capture.interfaces.map do |interface|
13
+ {name: interface.name, index: interface.index, linktype: interface.linktype,
14
+ snaplen: interface.snaplen, description: interface.description, mac: interface.mac,
15
+ mtu: interface.mtu, up: interface.up?, running: interface.running?, loopback: interface.loopback?}
16
+ end #: Array[Hash[Symbol, untyped]]
17
+ if RUBY_PLATFORM.include?("linux")
18
+ result << {name: "any", index: 0, linktype: 276, snaplen: 262_144,
19
+ description: "All interfaces", mac: nil, mtu: nil, up: true, running: true, loopback: false}
20
+ end
21
+ result
22
+ end
23
+
24
+ # @rbs (String name) -> Hash[Symbol, untyped]
25
+ def self.find(name)
26
+ list.find { |interface| interface[:name] == name } || raise(NotFound, "interface #{name.inspect} not found")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redhound"
4
+ require_relative "interfaces"
5
+ require_relative "capture_filter"
6
+
7
+ module Vanken
8
+ module Gateway
9
+ class LiveCapture
10
+ class Error < StandardError
11
+ attr_reader :code #: String
12
+ attr_reader :exit_code #: Integer
13
+
14
+ # @rbs (String message, code: String, ?exit_code: Integer) -> void
15
+ def initialize(message, code:, exit_code: 1)
16
+ @code, @exit_code = code, exit_code
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ attr_reader :backend #: Symbol
22
+
23
+ # @rbs (interface: String, ?backend: Symbol | String, ?snaplen: Integer, ?promiscuous: bool, ?buffer_size: Integer?, ?direction: Symbol, ?filter: String?) -> LiveCapture
24
+ def self.open(interface:, backend: :auto, snaplen: 262_144, promiscuous: true, buffer_size: nil, direction: :inout, filter: nil)
25
+ metadata = Interfaces.find(interface)
26
+ if filter && !filter.empty?
27
+ CaptureFilter.compile(filter, linktype: metadata[:linktype], snaplen: snaplen, live: RUBY_PLATFORM.include?("linux"))
28
+ end
29
+ new(Redhound::Capture.open(interface: interface, backend: backend, snaplen: snaplen, promiscuous: promiscuous,
30
+ buffer_size: buffer_size, direction: direction, filter: filter))
31
+ rescue Interfaces::NotFound, Redhound::InterfaceNotFound => e
32
+ raise Error.new(e.message, code: "interface_not_found")
33
+ rescue CaptureFilter::Error, Redhound::FilterError => e
34
+ raise Error.new(e.message, code: "invalid_filter", exit_code: 2)
35
+ rescue Redhound::PermissionDenied, Errno::EACCES, Errno::EPERM => e
36
+ raise Error.new(e.message, code: "permission_denied", exit_code: 3)
37
+ rescue Redhound::UnsupportedPlatform => e
38
+ raise Error.new(e.message, code: "unsupported_platform")
39
+ rescue Redhound::CaptureError, NotImplementedError, ArgumentError => e
40
+ raise Error.new(e.message, code: "backend_unavailable")
41
+ end
42
+
43
+ # @rbs (Redhound::Capture::Source source) -> void
44
+ def initialize(source)
45
+ @source = source
46
+ @backend = case source
47
+ when Redhound::Capture::Linux::TPacketV3 then :ring
48
+ when Redhound::Capture::Linux::PacketSocket then :socket
49
+ when Redhound::Capture::Bsd::BpfDevice then :bpf
50
+ else :unknown
51
+ end
52
+ end
53
+
54
+ # @rbs (?timeout: Numeric?) -> Redhound::Packet?
55
+ def next_packet(timeout: 0.05) = @source.next_packet(timeout: timeout)
56
+ # @rbs () -> bool
57
+ def stopped? = @source.stopped?
58
+ # @rbs () -> Hash[Symbol, Integer]
59
+ def stats = @source.stats.to_h
60
+ # @rbs () -> Integer
61
+ def linktype = @source.linktype
62
+ # @rbs () -> Array[Hash[Symbol, untyped]]
63
+ def interfaces
64
+ @source.interfaces.map do |interface|
65
+ {name: interface.name, linktype: interface.linktype, snaplen: interface.snaplen,
66
+ description: interface.description, mac: interface.mac, mtu: interface.mtu}
67
+ end
68
+ end
69
+
70
+ # @rbs () -> void
71
+ def stop = @source.stop
72
+ # @rbs () -> void
73
+ def close = @source.close
74
+
75
+ # @rbs () -> String
76
+ def self.version = Redhound::VERSION
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vanken
4
+ module Gateway
5
+ module FieldValues
6
+ ALIASES = {"ip.addr" => %w[ip.src ip.dst], "ipv6.addr" => %w[ipv6.src ipv6.dst],
7
+ "eth.addr" => %w[eth.src eth.dst], "tcp.port" => %w[tcp.srcport tcp.dstport],
8
+ "udp.port" => %w[udp.srcport udp.dstport]}.freeze
9
+ TCP_FLAGS = {"fin" => 1, "syn" => 2, "rst" => 4, "psh" => 8, "ack" => 16, "urg" => 32}.freeze
10
+
11
+ # @rbs (String name) { (String) -> Array[untyped] } -> Array[untyped]
12
+ def self.read(name)
13
+ if name.start_with?("tcp.flags.") && (bit = TCP_FLAGS[name.delete_prefix("tcp.flags.")])
14
+ return yield("tcp.flags").map { |flags| flags & bit != 0 }
15
+ end
16
+ ALIASES.fetch(name, [name]).flat_map { |field| yield field }.compact
17
+ end
18
+ end
19
+
20
+ # A field-only copy of stateful dissection, with an explicit binary JSON representation.
21
+ class PacketSnapshot
22
+ attr_reader :columns #: Core::column_values
23
+ attr_reader :annotations #: Core::annotation_values
24
+
25
+ # @rbs (fields: Hash[String, Array[untyped]], types: Hash[String, Symbol], layers: Array[String], columns: Core::column_values, annotations: Core::annotation_values) -> void
26
+ def initialize(fields:, types:, layers:, columns:, annotations:)
27
+ @fields, @types, @layers = fields, types, layers
28
+ @columns, @annotations = columns, annotations
29
+ end
30
+
31
+ # @rbs (PacketView packet) -> PacketSnapshot
32
+ def self.from_packet(packet)
33
+ groups = packet.fields.group_by(&:name)
34
+ new(fields: groups.transform_values { |fields| fields.map(&:value) },
35
+ types: groups.transform_values { |fields| fields.first.type }, layers: packet.columns[:layers],
36
+ columns: packet.columns, annotations: packet.annotations)
37
+ end
38
+
39
+ # @rbs (Hash[String, untyped] value) -> PacketSnapshot
40
+ def self.from_h(value)
41
+ new(fields: decode(value.fetch("fields")), types: value.fetch("types").transform_values(&:to_sym),
42
+ layers: value.fetch("layers"), columns: decode(value.fetch("columns")), annotations: decode(value.fetch("annotations")))
43
+ end
44
+
45
+ # @rbs () -> Hash[String, untyped]
46
+ def to_h
47
+ {"fields" => self.class.encode(@fields), "types" => @types.transform_values(&:to_s),
48
+ "layers" => @layers, "columns" => self.class.encode(@columns), "annotations" => self.class.encode(@annotations)}
49
+ end
50
+
51
+ # @rbs (String name) -> Array[untyped]
52
+ def values(name) = FieldValues.read(name) { |field| Array(@fields[field]) }
53
+ # @rbs (String name) -> Symbol?
54
+ def field_type(name) = @types[name]
55
+ # @rbs (String name) -> bool
56
+ def layer?(name) = @layers.include?(name)
57
+
58
+ # @rbs (untyped value) -> untyped
59
+ def self.encode(value)
60
+ case value
61
+ when String
62
+ value.encoding == Encoding::UTF_8 && value.valid_encoding? ? value :
63
+ {"$bytes" => value.unpack1("H*"), "$encoding" => value.encoding.name}
64
+ when Symbol then {"$symbol" => value.to_s}
65
+ when Array then value.map { |item| encode(item) }
66
+ when Hash then {"$hash" => value.map { |key, item| [encode(key), encode(item)] }}
67
+ when Integer, Float, TrueClass, FalseClass, NilClass then value
68
+ else raise TypeError, "unsupported snapshot value: #{value.class}"
69
+ end
70
+ end
71
+
72
+ # @rbs (untyped value) -> untyped
73
+ def self.decode(value)
74
+ case value
75
+ when Array then value.map { |item| decode(item) }
76
+ when Hash
77
+ if value.key?("$bytes")
78
+ [value.fetch("$bytes")].pack("H*").force_encoding(value.fetch("$encoding"))
79
+ elsif value.key?("$symbol")
80
+ value.fetch("$symbol").to_sym
81
+ else
82
+ value.fetch("$hash").to_h { |key, item| [decode(key), decode(item)] }
83
+ end
84
+ else value
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vanken
4
+ module UI
5
+ module Actions
6
+ def self.install(ui)
7
+ commands = {
8
+ open: ["開く", "cmd-o", -> { ui.open_dialog }], save: ["名前を付けて保存", "cmd-shift-s", -> { ui.save_dialog }],
9
+ close_file: ["ファイルを閉じる", "cmd-w", -> { ui.close_document }], reload: ["再読み込み", "cmd-r", -> { ui.reload_file }],
10
+ quit: ["終了", "cmd-q", -> { ui.quit }], preferences: ["設定", "cmd-,", -> { ui.preferences_dialog }],
11
+ capture: ["キャプチャ開始/停止", "cmd-e", -> { ui.capture_toggle }], restart: ["キャプチャ再開", nil, -> { ui.restart_capture }],
12
+ apply_filter: ["フィルタを適用", "enter", -> { ui.apply_filter }], clear_filter: ["フィルタをクリア", nil, -> { ui.clear_filter }],
13
+ filter_history: ["フィルタ履歴", nil, -> { ui.filter_history }], bookmark: ["フィルタを保存", nil, -> { ui.bookmark_filter }],
14
+ filter_selected: ["選択した項目でフィルタ", nil, -> { ui.filter_from_selection }],
15
+ prepare_selected: ["選択した項目のフィルタを準備", nil, -> { ui.filter_from_selection(prepare: true) }],
16
+ columns: ["表示する列", nil, -> { ui.columns_dialog }], autoscroll: ["末尾追従", nil, -> { ui.toggle_autoscroll }],
17
+ zoom_in: ["拡大", "cmd-+", -> { ui.zoom(1) }], zoom_out: ["縮小", "cmd--", -> { ui.zoom(-1) }], zoom_reset: ["標準サイズ", "cmd-0", -> { ui.zoom }],
18
+ previous_packet: ["前のパケット", "alt-up", -> { ui.navigate(-1) }], next_packet: ["次のパケット", "alt-down", -> { ui.navigate(1) }],
19
+ first_packet: ["最初のパケット", nil, -> { ui.first_packet }], last_packet: ["最後のパケット", nil, -> { ui.last_packet }],
20
+ go_to_packet: ["パケットへ移動", "cmd-g", -> { ui.go_to_packet }], copy_filter: ["項目をフィルタとしてコピー", nil, -> { ui.copy_text(ui.selected_node.filter) if ui.selected_node&.filter }]
21
+ }
22
+ %i[selected not and and_not or or_not].zip(["選択項目", "選択項目を除外", "AND", "AND NOT", "OR", "OR NOT"]).each do |mode, label|
23
+ commands[:"filter_#{mode}"] = ["適用: #{label}", nil, -> { ui.filter_from_selection(mode) }]
24
+ commands[:"prepare_#{mode}"] = ["準備: #{label}", nil, -> { ui.filter_from_selection(mode, prepare: true) }]
25
+ end
26
+ {hex: "16 進数", text: "テキスト", dump: "ダンプ", escaped: "エスケープした文字列"}.each do |format, label|
27
+ commands[:"copy_#{format}"] = ["バイトをコピー: #{label}", nil, -> { ui.copy_bytes(format) }]
28
+ end
29
+ commands.each do |name, (title, keys, operation)|
30
+ ui.app.actions.register(name, title: title) { |_| operation.call }
31
+ next unless keys
32
+ key = RUBY_PLATFORM.include?("darwin") ? keys : keys.sub("cmd", "ctrl")
33
+ ui.window.dispatcher.keymap.bind(key, name, context: name == :apply_filter ? "in_display_filter && !in_completion" : "")
34
+ end
35
+ ui.app.menu_bar = Zaniah::Menu.build do
36
+ submenu("ファイル") { %i[open save close_file reload quit].each { |action| item(action) } }
37
+ submenu("編集") { %i[copy_filter copy_hex copy_text copy_dump copy_escaped preferences].each { |action| item(action) } }
38
+ submenu("表示") { %i[columns autoscroll zoom_in zoom_out zoom_reset].each { |action| item(action) } }
39
+ submenu("移動") { %i[previous_packet next_packet first_packet last_packet go_to_packet].each { |action| item(action) } }
40
+ submenu("キャプチャ") { %i[capture restart].each { |action| item(action) } }
41
+ submenu("フィルタ") do
42
+ %i[apply_filter clear_filter filter_history bookmark].each { |action| item(action) }
43
+ submenu("選択項目を適用") { %i[selected not and and_not or or_not].each { |mode| item(:"filter_#{mode}") } }
44
+ submenu("選択項目から準備") { %i[selected not and and_not or or_not].each { |mode| item(:"prepare_#{mode}") } }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vanken"
4
+ require "zaniah/ui"
5
+ require "zaniah/process_pool"
6
+ require "uri"
7
+ require_relative "main_view"
8
+ require_relative "file_operations"
9
+ require_relative "filter_operations"
10
+ require_relative "selection"
11
+ require_relative "dialogs"
12
+ require_relative "actions"
13
+ require_relative "packet_source"
14
+ require_relative "../gateway/interfaces"
15
+ require_relative "../gateway/capture_filter"
16
+ require_relative "../app/capture_controller"
17
+ require_relative "../config/log"
18
+
19
+ module Vanken
20
+ module UI
21
+ class Application
22
+ include FileOperations, FilterOperations, Selection, Dialogs
23
+ attr_reader :app, :window, :preferences, :table, :tree, :hex, :filter_field, :capture,
24
+ :dialog_kind, :packet_source, :interface_infos
25
+
26
+ def initialize(backend: nil, preferences: Config::Preferences.new, debug: false)
27
+ @preferences = preferences
28
+ @log = Config::Log.open(debug: debug, directory: File.join(preferences.directory, "logs"))
29
+ @app = Zaniah::App.new
30
+ @entity = @app.new_entity { {document: nil, number: nil, node: nil, details: [], bytes: "".b, error: nil} }
31
+ @selection_generation, @file_generation, @filter_generation = 0, 0, 0
32
+ @pool_mutex = Mutex.new
33
+ @autoscroll = false
34
+ @backend = backend || (RUBY_PLATFORM.match?(/darwin/) ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux)
35
+ @filter_field = Zaniah::UI::TextField.new("", placeholder: "表示フィルタ", clearable: false).test_id("vk.filter.input")
36
+ @filter_field.completion(CompletionProvider.new(self)).on_change { |text, _| validate_filter(text) }
37
+ @packet_source = PacketSource.new(self)
38
+ @capture = App::CaptureController.new(preferences: preferences,
39
+ on_document: ->(document) { @app.executor.post { attach_document(document) unless @closing } },
40
+ on_update: ->(*) { @app.executor.post { changed unless @closing } })
41
+ @window = @app.open_window(backend: @backend, width: preferences.get("layout.width"), height: preferences.get("layout.height"), title: "Vanken")
42
+ unless @backend == :tui
43
+ @window.text_system = Zaniah::TextSystem::Renderer.new
44
+ @window.text_system.scale_factor = @window.scale_factor
45
+ end
46
+ @window.on_close do
47
+ if !@closing && (@capture.running? || document&.dirty?)
48
+ request_destructive { @closing = true; @window.close }
49
+ false
50
+ else
51
+ true
52
+ end
53
+ end
54
+ @window.on_state_change do |state|
55
+ @preferences.set("layout.width", state.frame.width.to_i.clamp(320, 65_536))
56
+ @preferences.set("layout.height", state.frame.height.to_i.clamp(240, 65_536))
57
+ end
58
+ Actions.install(self)
59
+ change_theme(preferences.get("appearance.theme"), persist: false)
60
+ @main_view = MainView.new(self).test_id("vk.main")
61
+ @window.draw { @main_view }
62
+ show_error(Vanken::ConfigError.new(preferences.warning)) if preferences.warning
63
+ @app.executor.background do
64
+ infos = Gateway::Interfaces.list
65
+ @app.executor.post do
66
+ next if @closing
67
+ @interface_infos = infos
68
+ changed
69
+ capture_options if @dialog_kind == :capture_options
70
+ end
71
+ rescue StandardError => error
72
+ @app.executor.post { @interface_error = error.message; changed }
73
+ end
74
+ end
75
+
76
+ def document = @app.read(@entity)[:document]
77
+ def selected_number = @app.read(@entity)[:number]
78
+ def selected_node = @app.read(@entity)[:node]
79
+ def detail_nodes = @app.read(@entity)[:details]
80
+ def selected_bytes = @app.read(@entity)[:bytes]
81
+ def update
82
+ @app.update(@entity) { |state, cx| yield(state); cx.notify }
83
+ end
84
+ def changed
85
+ return if @closing
86
+ @packet_source.refresh do
87
+ next if @closing
88
+ error = document&.error || @capture.error
89
+ @app.update(@entity) { |state, cx| state[:error] = error; cx.notify }
90
+ show_error(error) if error && @last_error != error
91
+ if document && @pending_filter && document.complete?
92
+ @pending_filter = false
93
+ apply_filter
94
+ end
95
+ end
96
+ end
97
+ def attach_document(value)
98
+ return if @closing
99
+ value.scanner = ->(payload) { filter_pool.submit(payload) } if value
100
+ @selection_generation += 1
101
+ update { |state| state.merge!(document: value, number: nil, node: nil, details: [], bytes: "".b) }
102
+ @packet_source.reset
103
+ @tree&.replace([])
104
+ @hex.bytes = "".b if @hex
105
+ @table&.selection&.clear
106
+ @autoscroll = !!(value && (value.source == :live || value.equal?(@capture.document)) && @preferences.get("packet_list.autoscroll"))
107
+ @table.follow_tail = @autoscroll if @table
108
+ @table.scroll_to(0) if @table && value&.displayed_count&.positive? && !@autoscroll
109
+ @window.title = "Vanken#{value&.path ? " — #{File.basename(value.path)}" : ""}" if @window.respond_to?(:title=)
110
+ end
111
+ def run = @app.run
112
+ def native? = !%i[headless tui].include?(@backend)
113
+ def monospace_font = @window.text_system&.font
114
+ def install_panes(table, tree, hex) = (@table, @tree, @hex = table, tree, hex)
115
+ def filter_pool
116
+ @pool_mutex.synchronize do
117
+ @pool ||= Zaniah::ProcessPool.new(workers: @preferences.get("analysis.workers"),
118
+ handler: "Vanken::Capture::FilterWorker", requires: [File.expand_path("../capture/filter_worker.rb", __dir__)],
119
+ load_paths: $LOAD_PATH.select { |path| File.directory?(path) })
120
+ end
121
+ end
122
+ def smoke
123
+ 3.times { @app.executor.drain; @window.tick }
124
+ end
125
+ def close
126
+ return if @closed
127
+ @closing = true
128
+ @packet_source.reset
129
+ @selection_generation += 1
130
+ @filter_generation += 1
131
+ @file_generation += 1
132
+ @validation_task&.cancel
133
+ @capture.close
134
+ document&.close
135
+ @pool&.shutdown
136
+ @app.executor.shutdown
137
+ @app.executor.drain
138
+ @window.close
139
+ @log.close
140
+ @closed = true
141
+ end
142
+ def quit = request_destructive { @closing = true; @window.close }
143
+
144
+ def change_theme(name, persist: true)
145
+ theme = name == "system" ? Zaniah::Theme.for(@window.appearance) : Zaniah::Theme.public_send(name)
146
+ size = @preferences.get("appearance.font_size")
147
+ theme = theme.with(typography: theme.typography.with(size_md: size, size_sm: size - 1, size_xs: size - 2))
148
+ @app.global(:theme, theme)
149
+ @preferences.set("appearance.theme", name) if persist
150
+ @window.request_frame
151
+ end
152
+ def zoom(delta = nil)
153
+ size = delta ? (@preferences.get("appearance.font_size") + delta).clamp(8, 32) : 13
154
+ @preferences.set("appearance.font_size", size)
155
+ change_theme(@preferences.get("appearance.theme"), persist: false)
156
+ end
157
+ def time_format(name)
158
+ @preferences.set("packet_list.time_format", name.to_s)
159
+ @packet_source.reset
160
+ @window.request_frame
161
+ end
162
+ def toggle_autoscroll
163
+ @autoscroll = !@autoscroll
164
+ @preferences.set("packet_list.autoscroll", @autoscroll)
165
+ @table.follow_tail = @autoscroll if @table
166
+ @table.scroll_to(@packet_source.count - 1, align: :end) if @autoscroll && @packet_source.count.positive?
167
+ @window.request_frame
168
+ end
169
+ def autoscroll? = @autoscroll && (!@table || @table.following_tail?)
170
+ def capture_toggle
171
+ return @capture.stop if @capture.running?
172
+ capture_options
173
+ end
174
+ def start_capture(options)
175
+ request_destructive do
176
+ dismiss_dialog
177
+ old = document
178
+ @app.executor.background { old&.close; @capture.start(options) unless @closing }
179
+ end
180
+ end
181
+ def restart_capture
182
+ request_destructive do
183
+ old = document
184
+ @capture.stop
185
+ @app.executor.background { @capture.wait; old&.close; @capture.start(@capture.options) unless @closing }
186
+ end
187
+ end
188
+ def status_text
189
+ doc = document
190
+ return "キャプチャファイルを開くか、インタフェースを選んで開始してください" unless doc
191
+ dropped = @capture.stats.fetch("dropped", @capture.stats.fetch(:dropped, 0))
192
+ total = doc.store.durable_count
193
+ "#{doc.loading? ? '読み込み中' : 'パケット'} #{@packet_source.packet_count} / #{total} 表示 #{@packet_source.count} ドロップ #{dropped}#{doc.progress ? " #{(doc.progress * 100).round}%" : ''}"
194
+ end
195
+ def copy_text(text) = @window.write_clipboard([Zaniah::Clipboard::Item.new({"text/plain" => text})])
196
+ def show_error(error)
197
+ @log.error("#{error.class}: #{error.message}")
198
+ @last_error = error
199
+ @dialog_kind = :error
200
+ @dialog = Zaniah::UI::Dialog.new(Zaniah::UI::Label.new(error.message, wrap: :word), title: "操作を完了できません").test_id("vk.error")
201
+ @window.request_frame
202
+ end
203
+ end
204
+ end
205
+ end