smart_proxy_openbolt 0.0.1

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.
@@ -0,0 +1,326 @@
1
+ require 'json'
2
+ require 'open3'
3
+ require 'smart_proxy_openbolt/executor'
4
+ require 'smart_proxy_openbolt/error'
5
+ require 'thread'
6
+
7
+ module Proxy::OpenBolt
8
+ extend ::Proxy::Util
9
+ extend ::Proxy::Log
10
+
11
+ TRANSPORTS = ['ssh', 'winrm']
12
+ # The key should be exactly the flag name passed to OpenBolt
13
+ # Type must be :boolean, :string, or an array of acceptable string values
14
+ # Transport must be an array of transport types it applies to. This is
15
+ # used to filter the openbolt options in the UI to only those relevant
16
+ # Defaults set here are in case the UI does not send any information for
17
+ # the key, and should only be present if this value is required
18
+ # Sensitive should be set to true in order to redact the value from logs
19
+ OPENBOLT_OPTIONS = {
20
+ 'transport' => {
21
+ :type => TRANSPORTS,
22
+ :transport => TRANSPORTS,
23
+ :default => 'ssh',
24
+ :sensitive => false,
25
+ :description => 'The transport method to use for connecting to target hosts.',
26
+ },
27
+ 'log-level' => {
28
+ :type => ['error', 'warning', 'info', 'debug', 'trace'],
29
+ :transport => ['ssh', 'winrm'],
30
+ :sensitive => false,
31
+ :description => 'Set the log level during OpenBolt execution.',
32
+ },
33
+ 'verbose' => {
34
+ :type => :boolean,
35
+ :transport => ['ssh', 'winrm'],
36
+ :sensitive => false,
37
+ :description => 'Run the OpenBolt command with the --verbose flag. This prints additional information during OpenBolt execution and will print any out::verbose plan statements.',
38
+ },
39
+ 'noop' => {
40
+ :type => :boolean,
41
+ :transport => ['ssh', 'winrm'],
42
+ :sensitive => false,
43
+ :description => 'Run the OpenBolt command with the --noop flag, which will make no changes to the target host.',
44
+ },
45
+ 'tmpdir' => {
46
+ :type => :string,
47
+ :transport => ['ssh', 'winrm'],
48
+ :sensitive => false,
49
+ :description => 'Directory to use for temporary files on target hosts during OpenBolt execution.',
50
+ },
51
+ 'user' => {
52
+ :type => :string,
53
+ :transport => ['ssh', 'winrm'],
54
+ :sensitive => false,
55
+ :description => 'Username used for SSH or WinRM authentication.',
56
+ },
57
+ 'password' => {
58
+ :type => :string,
59
+ :transport => ['ssh', 'winrm'],
60
+ :sensitive => true,
61
+ :description => 'Password used for SSH or WinRM authentication.',
62
+ },
63
+ 'host-key-check' => {
64
+ :type => :boolean,
65
+ :transport => ['ssh'],
66
+ :sensitive => false,
67
+ :description => 'When enabled, perform host key verification when connecting to targets over SSH.',
68
+ },
69
+ 'private-key' => {
70
+ :type => :string,
71
+ :transport => ['ssh'],
72
+ :sensitive => false,
73
+ :description => 'Path on the smart proxy host to the private key used for SSH authentication. This key must be readable by the foreman-proxy user.',
74
+ },
75
+ 'run-as' => {
76
+ :type => :string,
77
+ :transport => ['ssh'],
78
+ :sensitive => false,
79
+ :description => 'The user to run commands as on the target host. This requires that the user specified in the "user" option has permission to run commands as this user.',
80
+ },
81
+ 'sudo-password' => {
82
+ :type => :string,
83
+ :transport => ['ssh'],
84
+ :sensitive => true,
85
+ :description => 'Password used for privilege escalation when using SSH.',
86
+ },
87
+ 'ssl' => {
88
+ :type => :boolean,
89
+ :transport => ['winrm'],
90
+ :sensitive => false,
91
+ :description => 'Use SSL when connecting to hosts via WinRM.',
92
+ },
93
+ 'ssl-verify' => {
94
+ :type => :boolean,
95
+ :transport => ['winrm'],
96
+ :sensitive => false,
97
+ :description => 'Verify remote host SSL certificate when connecting to hosts via WinRM.',
98
+ },
99
+ }
100
+ class << self
101
+ @@mutex = Mutex.new
102
+
103
+ def openbolt_options
104
+ OPENBOLT_OPTIONS.sort.to_h
105
+ end
106
+
107
+ def executor
108
+ @executor ||= Proxy::OpenBolt::Executor.instance
109
+ end
110
+
111
+ # /tasks or /tasks/reload
112
+ def tasks(reload: false)
113
+ # If we need to reload, only one instance of the reload
114
+ # should happen at once. Make others wait until it is
115
+ # finished.
116
+ @@mutex.synchronize do
117
+ @tasks = nil if reload
118
+ @tasks || reload_tasks
119
+ end
120
+ end
121
+
122
+ def reload_tasks
123
+ task_data = {}
124
+
125
+ # Get a list of all tasks
126
+ command = "bolt task show --project #{Proxy::OpenBolt::Plugin.settings.environment_path} --format json"
127
+ stdout, stderr, status = openbolt(command)
128
+ unless status.exitstatus.zero?
129
+ raise Proxy::OpenBolt::CliError.new(
130
+ message: 'Error occurred when fetching tasks names.',
131
+ exitcode: status.exitstatus,
132
+ stdout: stdout,
133
+ stderr: stderr,
134
+ command: command,
135
+ )
136
+ end
137
+ task_names = []
138
+ begin
139
+ task_names = JSON.parse(stdout)['tasks'].map { |t| t[0] }
140
+ rescue JSON::ParserError => e
141
+ raise Proxy::OpenBolt::Error.new(
142
+ message: "Error occurred when parsing 'bolt task show' output.",
143
+ exception: e,
144
+ )
145
+ end
146
+
147
+ # Get metadata for each task and put into @tasks
148
+ task_names.each do |name|
149
+ command = "bolt task show #{name} --project #{Proxy::OpenBolt::Plugin.settings.environment_path} --format json"
150
+ stdout, stderr, status = openbolt(command)
151
+ unless status.exitstatus.zero?
152
+ @tasks = nil
153
+ raise Proxy::OpenBolt::CliError.new(
154
+ message: "Error occurred when fetching task information for #{name}",
155
+ exitcode: status.exitstatus,
156
+ stdout: stdout,
157
+ stderr: stderr,
158
+ command: command,
159
+ )
160
+ end
161
+ metadata = {}
162
+ begin
163
+ metadata = JSON.parse(stdout)['metadata']
164
+ rescue Json::ParserError => e
165
+ @tasks = nil
166
+ raise Proxy::OpenBolt::Error.new(
167
+ message: "Error occurred when parsing 'bolt task show #{name}' output.",
168
+ exception: e,
169
+ )
170
+ end
171
+ if metadata.nil?
172
+ @tasks = nil
173
+ raise Proxy::OpenBolt::Error.new(
174
+ message: "Invalid metadata found for task #{name}",
175
+ output: output,
176
+ command: command,
177
+ )
178
+ end
179
+ task_data[name] = {
180
+ 'description' => metadata['description'] || '',
181
+ 'parameters' => metadata['parameters'] || {},
182
+ }
183
+ end
184
+ @tasks = task_data
185
+ end
186
+
187
+ # Normalize options and parameters, since the UI may send unspecified options as empty strings
188
+ def normalize_values(hash)
189
+ return {} unless hash.is_a?(Hash)
190
+ hash.transform_values do |value|
191
+ if value.is_a?(String)
192
+ value = value.strip
193
+ value = nil if value.empty?
194
+ elsif value.is_a?(Array)
195
+ value = value.map { |v| v.is_a?(String) ? v.strip : v }
196
+ value = nil if value.empty?
197
+ end
198
+ value
199
+ end.compact
200
+ end
201
+
202
+ # /launch/task
203
+ def launch_task(data)
204
+ ### Validation ###
205
+ unless data.is_a?(Hash)
206
+ raise Proxy::OpenBolt::Error.new(message: 'Data passed in to launch_task function is not a hash. This is most likely a bug in the smart_proxy_openbolt plugin. Please file an issue with the maintainers.').to_json
207
+ end
208
+ fields = ['name', 'parameters', 'targets', 'options']
209
+ unless fields.all? { |k| data.keys.include?(k) }
210
+ raise Proxy::OpenBolt::Error.new(message: "You must provide values for 'name', 'parameters', 'targets', and 'transport'.")
211
+ end
212
+ name = data['name']
213
+ params = data['parameters'] || {}
214
+ targets = data['targets']
215
+ options = data['options']
216
+
217
+ logger.info("Task: #{name}")
218
+ logger.info("Parameters: #{params.inspect}")
219
+ logger.info("Targets: #{targets.inspect}")
220
+ logger.info("Options: #{scrub(options, options.inspect.to_s)}")
221
+
222
+ # Validate name
223
+ raise Proxy::OpenBolt::Error.new(message: "You must provide a value for 'name'.") unless name.is_a?(String) && !name.empty?
224
+ raise Proxy::OpenBolt::Error.new(message: "Task #{name} not found.") unless tasks.keys.include?(name)
225
+
226
+ # Validate parameters
227
+ raise Proxy::OpenBolt::Error.new(message: "The 'parameters' value should be a hash.") unless params.is_a?(Hash)
228
+ missing = []
229
+ tasks[name]['parameters'].each do |k, v|
230
+ next if v['type'].start_with?('Optional[')
231
+ missing << k unless params.keys.include?(k)
232
+ end
233
+ raise Proxy::OpenBolt::Error.new(message: "Missing required parameters: #{missing}") unless missing.empty?
234
+ extra = params.keys - tasks[name]['parameters'].keys
235
+ raise Proxy::OpenBolt::Error.new(message: "Unknown parameters: #{extra}") unless extra.empty?
236
+
237
+ # Normalize parameters, ensuring blank values are not passed
238
+ params = normalize_values(params)
239
+ logger.info("Normalized parameters: #{params.inspect}")
240
+
241
+ # Validate targets
242
+ raise Proxy::OpenBolt::Error.new(message: "The 'targets' value should be a string or an array.'") unless targets.is_a?(String) || targets.is_a?(Array)
243
+ targets = targets.split(',').map { |t| t.strip }
244
+ raise Proxy::OpenBolt::Error.new(message: "The 'targets' value should not be empty.") if targets.empty?
245
+
246
+ options ||= {}
247
+ # Validate options
248
+ raise Proxy::OpenBolt::Error.new(message: "The 'options' value should be a hash.") unless options.is_a?(Hash)
249
+ extra = options.keys - OPENBOLT_OPTIONS.keys
250
+ raise Proxy::OpenBolt::Error.new(message: "Invalid options specified: #{extra}") unless extra.empty?
251
+ unknown = options.keys - OPENBOLT_OPTIONS.keys
252
+ raise Proxy::OpenBolt::Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty?
253
+
254
+ # Normalize options, removing blank values
255
+ options = normalize_values(options)
256
+ logger.info("Normalized options: #{scrub(options, options.inspect.to_s)}")
257
+ OPENBOLT_OPTIONS.each { |key, value| options[key] ||= value[:default] if value.key?(:default) }
258
+ logger.info("Options with required defaults: #{scrub(options, options.inspect.to_s)}")
259
+
260
+ # Validate option types
261
+ options = options.map do |key, value|
262
+ type = OPENBOLT_OPTIONS[key][:type]
263
+ value = value.nil? ? '' : value # Just in case
264
+ case type
265
+ when :boolean
266
+ if value.is_a?(String)
267
+ value = value.downcase.strip
268
+ raise Proxy::OpenBolt::Error.new(message: "Option #{key} must be a boolean 'true' or 'false'. Current value: #{value}") unless ['true', 'false'].include?(value)
269
+ value = value == 'true'
270
+ end
271
+ raise Proxy::OpenBolt::Error.new(message: "Option #{key} must be a boolean true for false. It appears to be #{value.class}.") unless [TrueClass, FalseClass].include?(value.class)
272
+ when :string
273
+ value = value.strip
274
+ raise Proxy::OpenBolt::Error.new(message: "Option #{key} must have a value when the option is specified.") if value.empty?
275
+ when Array
276
+ value = value.strip
277
+ raise Proxy::OpenBolt::Error.new(message: "Option #{key} must have one of the following values: #{OPENBOLT_OPTIONS[key][:type]}") unless OPENBOLT_OPTIONS[key][:type].include?(value)
278
+ end
279
+ [key, value]
280
+ end.to_h
281
+ logger.info("Final options: #{scrub(options, options.inspect.to_s)}")
282
+
283
+ ### Run the task ###
284
+ task = TaskJob.new(name, params, options, targets)
285
+ id = executor.add_job(task)
286
+
287
+ return {
288
+ id: id
289
+ }.to_json
290
+ end
291
+
292
+ # /job/:id/status
293
+ def get_status(id)
294
+ return {
295
+ status: executor.status(id),
296
+ }.to_json
297
+ end
298
+
299
+ # /job/:id/result
300
+ def get_result(id)
301
+ executor.result(id).to_json
302
+ end
303
+
304
+ # Anything that needs to run an OpenBolt CLI command should use this.
305
+ # At the moment, the full output is held in memory and passed back.
306
+ # If this becomes a problem, we can stream to disk and point to it.
307
+ #
308
+ # For task runs, the log goes to stderr and the result to stdout when
309
+ # --format json is specified. At some point, figure out how to make
310
+ # OpenBolt's logger log to a file instead without having to have a special
311
+ # project config file.
312
+ def openbolt(command)
313
+ env = { 'BOLT_GEM' => 'true', 'BOLT_DISABLE_ANALYTICS' => 'true' }
314
+ Open3.capture3(env, *command.split)
315
+ end
316
+
317
+ # Probably needs to go in a utils class somewhere
318
+ # Used only for display text that may contain sensitive OpenBolt
319
+ # options values. Should to be used to pass anything to the CLI.
320
+ def scrub(options, text)
321
+ sensitive = options.select { |key, _| OPENBOLT_OPTIONS[key] && OPENBOLT_OPTIONS[key][:sensitive] }
322
+ sensitive.each { |_, value| text = text.gsub(value, '*****') }
323
+ text
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,40 @@
1
+ require 'fileutils'
2
+
3
+ module Proxy::OpenBolt
4
+ class NotFound < RuntimeError; end
5
+
6
+ class LogPathValidator < ::Proxy::PluginValidators::Base
7
+ def validate!(settings)
8
+ logdir = settings[:log_dir]
9
+ unless Dir.exist?(logdir)
10
+ FileUtils.mkdir_p(logdir)
11
+ FileUtils.chown('foreman-proxy','foreman-proxy',logdir)
12
+ FileUtils.chmod(0750, logdir)
13
+ end
14
+ raise ::Proxy::Error::ConfigurationError("Could not create log dir at #{logdir}") unless Dir.exist?(logdir)
15
+ end
16
+ end
17
+
18
+ class Plugin < ::Proxy::Plugin
19
+ plugin :openbolt, Proxy::OpenBolt::VERSION
20
+
21
+ expose_setting :enabled
22
+
23
+ capability :tasks
24
+
25
+ # TODO: Validate this is a valid path
26
+ default_settings(
27
+ environment_path: '/etc/puppetlabs/code/environments/production',
28
+ workers: 20,
29
+ concurrency: 100,
30
+ connect_timeout: 30,
31
+ log_dir: '/var/log/foreman-proxy/openbolt'
32
+ )
33
+
34
+ load_validators :log_path_validator => Proxy::OpenBolt::LogPathValidator
35
+ validate_readable :environment_path
36
+ validate :log_dir, :log_path_validator => true
37
+
38
+ https_rackup_path File.expand_path('http_config.ru', File.expand_path('../', __FILE__))
39
+ end
40
+ end
@@ -0,0 +1,72 @@
1
+ require 'json'
2
+
3
+ module Proxy::OpenBolt
4
+ class Result
5
+
6
+ attr_reader :command, :status, :value, :log, :message, :schema
7
+
8
+ # Result from the OpenBolt CLI with --format json looks like:
9
+ #
10
+ # { "items": [
11
+ # {
12
+ # "target": "certname1",
13
+ # "action": "task",
14
+ # "object": "task::name",
15
+ # "status": "success",
16
+ # "value": <whatever the task returns>
17
+ # },
18
+ # {
19
+ # "target": "certname2",
20
+ # ...
21
+ # }
22
+ # ],
23
+ # "target_count": 2,
24
+ # "elapsed_time": 3
25
+ # }
26
+
27
+ # This class will take the raw stdout, stderr, status.exitcode objects from a
28
+ # OpenBolt CLI invocation, and parse them accordingly. This should only be
29
+ # used with the --format json flag passed to the OpenBolt CLI, as that changes
30
+ # what data gets put on stdout and stderr.
31
+ #
32
+ # The "exception" parameter is to be able to handle an unexpected exception,
33
+ # and should generally not be used except where it is right now.
34
+ def initialize(command, stdout, stderr, exitcode)
35
+ @schema = 1
36
+ @command = command
37
+ if exitcode > 1
38
+ @message = "Command unexpectedly exited with code #{exitcode}"
39
+ @status = :exception
40
+ @value = "stderr:\n#{stderr}\nstdout:\n#{stdout}"
41
+ else
42
+ if exitcode == 1 && !stdout.start_with?('{')
43
+ @value = stdout
44
+ @status = :failure
45
+ @log = stderr
46
+ else
47
+ begin
48
+ @value = JSON.parse(stdout)
49
+ @status = exitcode == 0 ? :success : :failure
50
+ @log = stderr
51
+ rescue JSON::ParserError => e
52
+ @status = :exception
53
+ @message = e.message
54
+ @value = e.inspect
55
+ @log = stderr
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def to_json
62
+ {
63
+ 'command': @command,
64
+ 'status': @status,
65
+ 'value': @value,
66
+ 'log': @log,
67
+ 'message': @message,
68
+ 'schema': @schema,
69
+ }.to_json
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,73 @@
1
+ require 'json'
2
+ require 'smart_proxy_openbolt/error'
3
+ require 'smart_proxy_openbolt/job'
4
+ require 'smart_proxy_openbolt/main'
5
+ require 'smart_proxy_openbolt/result'
6
+
7
+ module Proxy::OpenBolt
8
+ class TaskJob < Job
9
+ attr_reader :targets
10
+
11
+ # NOTE: Validation of all objects initialized here should be done in
12
+ # main.rb BEFORE creating this object.
13
+ def initialize(name, parameters, options, targets)
14
+ super(name, parameters, options)
15
+ @targets = targets
16
+ end
17
+
18
+ def execute
19
+ command = get_cmd
20
+ stdout, stderr, status = Proxy::OpenBolt.openbolt(command)
21
+ Proxy::OpenBolt::Result.new(
22
+ Proxy::OpenBolt.scrub(@options, command),
23
+ Proxy::OpenBolt.scrub(@options, stdout),
24
+ Proxy::OpenBolt.scrub(@options, stderr),
25
+ status.exitstatus
26
+ )
27
+ end
28
+
29
+ def get_cmd
30
+ # Service config settings (not per-task)
31
+ concurrency = "--concurrency=#{Proxy::OpenBolt::Plugin.settings.concurrency}"
32
+ connect_timeout = "--connect-timeout=#{Proxy::OpenBolt::Plugin.settings.connect_timeout}"
33
+ "bolt task run #{@name} --targets #{@targets.join(',')} --no-save-rerun #{concurrency} #{connect_timeout} --project #{Proxy::OpenBolt::Plugin.settings.environment_path} --format json --no-color #{parse_options} #{parse_parameters}"
34
+ end
35
+
36
+ def parse_parameters
37
+ params = []
38
+ @parameters.each do |key, value|
39
+ if value.is_a?(Array)
40
+ params << "#{key}='#{value}'"
41
+ elsif value.is_a?(Hash)
42
+ params << "#{key}='#{value.to_json}'"
43
+ else
44
+ params << "#{key}=#{value}"
45
+ end
46
+ end
47
+ params.join(' ')
48
+ end
49
+
50
+ def parse_options
51
+ opt_str = ''
52
+ if @options
53
+ @options.each do |key, value|
54
+ # --noop doesn't have a --[no-] prefix
55
+ next if key == 'noop' && value.is_a?(FalseClass)
56
+ # For some mindboggling reason, there are both '--log-level trace'
57
+ # and '--trace' options. We only expose log level, so just
58
+ # tack on --trace if that's what we find.
59
+ if key == 'log-level' && value == 'trace'
60
+ opt_str += "--log-level=trace --trace "
61
+ elsif value.is_a?(TrueClass)
62
+ opt_str += "--#{key} "
63
+ elsif value.is_a?(FalseClass)
64
+ opt_str += "--no-#{key} "
65
+ else
66
+ opt_str += "--#{key}=#{value} "
67
+ end
68
+ end
69
+ end
70
+ opt_str
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ module Proxy
2
+ module OpenBolt
3
+ VERSION = '0.0.1'
4
+ end
5
+ end
@@ -0,0 +1,2 @@
1
+ require 'smart_proxy_openbolt/version'
2
+ require 'smart_proxy_openbolt/plugin'
@@ -0,0 +1,7 @@
1
+ ---
2
+ :enabled: https
3
+ :environment_path: /etc/puppetlabs/code/environments/production
4
+ :workers: 20
5
+ :concurrency: 100
6
+ :connect_timeout: 30
7
+ :log_dir: /var/log/foreman-proxy/openbolt
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smart_proxy_openbolt
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Overlook InfraTech
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 1.3.5
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '1.3'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 1.3.5
32
+ description: Uses the OpenBolt CLI tool to run tasks and plans in Foreman
33
+ email: contact@overlookinfratech.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files:
37
+ - LICENSE
38
+ - README.md
39
+ files:
40
+ - LICENSE
41
+ - README.md
42
+ - bundler.d/openbolt.rb
43
+ - lib/smart_proxy_openbolt.rb
44
+ - lib/smart_proxy_openbolt/api.rb
45
+ - lib/smart_proxy_openbolt/error.rb
46
+ - lib/smart_proxy_openbolt/executor.rb
47
+ - lib/smart_proxy_openbolt/http_config.ru
48
+ - lib/smart_proxy_openbolt/job.rb
49
+ - lib/smart_proxy_openbolt/main.rb
50
+ - lib/smart_proxy_openbolt/plugin.rb
51
+ - lib/smart_proxy_openbolt/result.rb
52
+ - lib/smart_proxy_openbolt/task_job.rb
53
+ - lib/smart_proxy_openbolt/version.rb
54
+ - settings.d/openbolt.yml
55
+ homepage: http://github.com/overlookinfra/smart_proxy_openbolt
56
+ licenses:
57
+ - GPL-3.0-only
58
+ metadata: {}
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: Smart Proxy plugin for OpenBolt integration
76
+ test_files: []