Module: Proxy::OpenBolt

Extended by:
Log, Util
Defined in:
lib/smart_proxy_openbolt/version.rb,
lib/smart_proxy_openbolt/executor.rb,
lib/smart_proxy_openbolt/task_job.rb,
lib/smart_proxy_openbolt/lru_cache.rb,
lib/smart_proxy_openbolt/result.rb,
lib/smart_proxy_openbolt/plugin.rb,
lib/smart_proxy_openbolt/error.rb,
lib/smart_proxy_openbolt/main.rb,
lib/smart_proxy_openbolt/job.rb,
lib/smart_proxy_openbolt/api.rb

Defined Under Namespace

Classes: Api, CliError, Error, Executor, Job, LogPathValidator, LruCache, Plugin, Result, TaskJob

Constant Summary collapse

VERSION =
'1.1.0'.freeze
TRANSPORTS =
['ssh', 'winrm'].freeze
OPENBOLT_OPTIONS =

The key should be exactly the flag name passed to OpenBolt Type must be :boolean, :string, or an array of acceptable string values Transport must be an array of transport types it applies to. This is

used to filter the openbolt options in the UI to only those relevant

Defaults set here are in case the UI does not send any information for

the key, and should only be present if this value is required

Sensitive should be set to true in order to redact the value from logs

{
  'transport' => {
    :type => TRANSPORTS,
    :transport => TRANSPORTS,
    :default => 'ssh',
    :sensitive => false,
    :description => 'The transport method to use for connecting to target hosts.',
  },
  'log-level' => {
    :type => ['error', 'warning', 'info', 'debug', 'trace'],
    :transport => ['ssh', 'winrm'],
    :sensitive => false,
    :description => 'Set the log level during OpenBolt execution.',
  },
  'verbose' => {
    :type => :boolean,
    :transport => ['ssh', 'winrm'],
    :sensitive => false,
    :description => 'Run the OpenBolt command with the --verbose flag. This prints additional information during OpenBolt execution and will print any out::verbose plan statements.',
  },
  'noop' => {
    :type => :boolean,
    :transport => ['ssh', 'winrm'],
    :sensitive => false,
    :description => 'Run the OpenBolt command with the --noop flag, which will make no changes to the target host.',
  },
  'tmpdir' => {
    :type => :string,
    :transport => ['ssh', 'winrm'],
    :sensitive => false,
    :description => 'Directory to use for temporary files on target hosts during OpenBolt execution.',
  },
  'user' => {
    :type => :string,
    :transport => ['ssh', 'winrm'],
    :sensitive => false,
    :description => 'Username used for SSH or WinRM authentication.',
  },
  'password' => {
    :type => :string,
    :transport => ['ssh', 'winrm'],
    :sensitive => true,
    :description => 'Password used for SSH or WinRM authentication.',
  },
  'host-key-check' => {
    :type => :boolean,
    :transport => ['ssh'],
    :sensitive => false,
    :description => 'When enabled, perform host key verification when connecting to targets over SSH.',
  },
  'private-key' => {
    :type => :string,
    :transport => ['ssh'],
    :sensitive => false,
    :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.',
  },
  'run-as' => {
    :type => :string,
    :transport => ['ssh'],
    :sensitive => false,
    :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.',
  },
  'sudo-password' => {
    :type => :string,
    :transport => ['ssh'],
    :sensitive => true,
    :description => 'Password used for privilege escalation when using SSH.',
  },
  'ssl' => {
    :type => :boolean,
    :transport => ['winrm'],
    :sensitive => false,
    :description => 'Use SSL when connecting to hosts via WinRM.',
  },
  'ssl-verify' => {
    :type => :boolean,
    :transport => ['winrm'],
    :sensitive => false,
    :description => 'Verify remote host SSL certificate when connecting to hosts via WinRM.',
  },
}.freeze
SORTED_OPTIONS =
OPENBOLT_OPTIONS.sort.to_h.freeze

Class Method Summary collapse

Class Method Details

.delete_artifacts(id) ⇒ Object

DELETE /job/:id/artifacts



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/smart_proxy_openbolt/main.rb', line 292

def delete_artifacts(id)
  validate_job_id!(id)

  file_path = result_file_path(id)
  real_path = File.realpath(file_path)
  expected_dir = File.realpath(Plugin.settings.log_dir)
  raise Error.new(message: 'Invalid file path') unless real_path.start_with?(expected_dir)

  File.delete(file_path)
  executor.remove_job(id)
  logger.info("Deleted artifacts for job #{id}")
  { status: 'deleted', job_id: id }.to_json
rescue Errno::ENOENT
  logger.warning("Artifacts not found for job #{id}")
  { status: 'not_found', job_id: id }.to_json
end

.executorObject



108
109
110
# File 'lib/smart_proxy_openbolt/main.rb', line 108

def executor
  @executor ||= Executor.instance
end

.get_result(id) ⇒ Object

/job/:id/result



281
282
283
284
285
286
287
288
289
# File 'lib/smart_proxy_openbolt/main.rb', line 281

def get_result(id)
  validate_job_id!(id)
  result = executor.result(id)
  return result if result.is_a?(String)
  raise Error.new(message: "Job not found: #{id}") if result == :invalid
  result.to_json
rescue Errno::ENOENT
  raise Error.new(message: "Result file not found for job: #{id}")
end

.get_status(id) ⇒ Object

/job/:id/status



275
276
277
278
# File 'lib/smart_proxy_openbolt/main.rb', line 275

def get_status(id)
  validate_job_id!(id)
  { status: executor.status(id) }.to_json
end

.launch_task(data) ⇒ Object

/launch/task

Raises:



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/smart_proxy_openbolt/main.rb', line 185

def launch_task(data)
  ### Validation ###
  unless data.is_a?(Hash)
    raise 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.')
  end
  fields = ['name', 'parameters', 'targets', 'options']
  unless fields.all? { |k| data.key?(k) }
    raise Error.new(message: "You must provide values for 'name', 'parameters', 'targets', and 'options'.")
  end
  name = data['name']
  params = data['parameters'] || {}
  targets = data['targets']
  options = data['options'] || {}

  logger.info("Task: #{name}")
  logger.info("Parameters: #{params.inspect}")
  logger.info("Targets: #{targets.inspect}")
  logger.info("Options: #{scrub(options, options.inspect)}")

  # Validate name
  raise Error.new(message: "You must provide a value for 'name'.") unless name.is_a?(String) && !name.empty?
  raise Error.new(message: "Task #{name} not found.") unless tasks.key?(name)

  # Validate parameters
  raise Error.new(message: "The 'parameters' value should be a hash.") unless params.is_a?(Hash)
  extra = params.keys - tasks[name]['parameters'].keys
  raise Error.new(message: "Unknown parameters: #{extra}") unless extra.empty?

  # Normalize parameters, ensuring blank values are not passed
  params = normalize_values(params)
  logger.info("Normalized parameters: #{params.inspect}")

  # Check required parameters after normalization so blank values are caught
  missing = []
  tasks[name]['parameters'].each do |k, v|
    next if v['type']&.start_with?('Optional[')
    next if v.key?('default')
    missing << k unless params.key?(k)
  end
  raise Error.new(message: "Missing required parameters: #{missing}") unless missing.empty?

  # Validate targets
  raise Error.new(message: "The 'targets' value should be a string or an array.") unless targets.is_a?(String) || targets.is_a?(Array)
  if targets.is_a?(Array)
    raise Error.new(message: "All target values must be strings.") unless targets.all?(String)
    targets = targets.map(&:strip).reject(&:empty?)
  else
    targets = targets.split(',').map(&:strip).reject(&:empty?)
  end
  raise Error.new(message: "The 'targets' value should not be empty.") if targets.empty?

  # Validate options
  raise Error.new(message: "The 'options' value should be a hash.") unless options.is_a?(Hash)
  unknown = options.keys - OPENBOLT_OPTIONS.keys
  raise Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty?

  # Normalize options, removing blank values
  options = normalize_values(options)
  logger.info("Normalized options: #{scrub(options, options.inspect)}")
  OPENBOLT_OPTIONS.each { |key, value| options[key] ||= value[:default] if value.key?(:default) }
  logger.info("Options with required defaults: #{scrub(options, options.inspect)}")

  # Validate option types
  options = options.to_h do |key, value|
    type = OPENBOLT_OPTIONS[key][:type]
    case type
    when :boolean
      if value.is_a?(String)
        value = value.downcase.strip
        raise Error.new(message: "Option #{key} must be a boolean 'true' or 'false'. Current value: #{value}") unless ['true', 'false'].include?(value)
        value = value == 'true'
      end
      raise Error.new(message: "Option #{key} must be a boolean true or false. It appears to be #{value.class}.") unless [TrueClass, FalseClass].include?(value.class)
    when :string
      raise Error.new(message: "Option #{key} must have a value when the option is specified.") if value.to_s.empty?
    when Array
      raise Error.new(message: "Option #{key} must have one of the following values: #{OPENBOLT_OPTIONS[key][:type]}") unless OPENBOLT_OPTIONS[key][:type].include?(value.to_s)
    end
    [key, value]
  end
  logger.info("Final options: #{scrub(options, options.inspect)}")

  ### Run the task ###
  task = TaskJob.new(name, params, options, targets)
  id = executor.add_job(task)

  { id: id }.to_json
end

.normalize_values(hash) ⇒ Object

Normalize options and parameters, since the UI may send unspecified options as empty strings



170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/smart_proxy_openbolt/main.rb', line 170

def normalize_values(hash)
  return {} unless hash.is_a?(Hash)
  hash.transform_values do |value|
    if value.is_a?(String)
      value = value.strip
      value = nil if value.empty?
    elsif value.is_a?(Array)
      value = value.map { |v| v.is_a?(String) ? v.strip : v }
      value = nil if value.empty?
    end
    value
  end.compact
end

.openbolt(command) ⇒ Object

Anything that needs to run an OpenBolt CLI command should use this. At the moment, the full output is held in memory and passed back. If this becomes a problem, we can stream to disk and point to it.

For task runs, the log goes to stderr and the result to stdout when –format json is specified. At some point, figure out how to make OpenBolt’s logger log to a file instead without having to have a special project config file. Returns [stdout, stderr, exitcode]. Handles the case where the process is killed by a signal (exitstatus is nil).



343
344
345
346
347
348
349
350
351
352
353
# File 'lib/smart_proxy_openbolt/main.rb', line 343

def openbolt(command)
  env = { 'BOLT_GEM' => 'true', 'BOLT_DISABLE_ANALYTICS' => 'true' }
  stdout, stderr, status = Open3.capture3(env, *command)
  exitcode = status.exitstatus
  if exitcode.nil?
    # 128 + signal follows the Unix/shell convention for signal exit codes.
    exitcode = 128 + (status.termsig || 0)
    stderr = "Process was killed by signal #{status.termsig}.\n#{stderr}"
  end
  [stdout, stderr, exitcode]
end

.openbolt_json(command) ⇒ Object

Runs an openbolt command that is expected to produce JSON on stdout. Returns the parsed JSON hash. Raises CliError on non-zero exit or Error on JSON parse failure.



312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/smart_proxy_openbolt/main.rb', line 312

def openbolt_json(command)
  stdout, stderr, exitcode = openbolt(command)
  unless exitcode.zero?
    raise CliError.new(
      message:  "Error running '#{command.first(4).join(' ')}'.",
      exitcode: exitcode,
      stdout:   stdout,
      stderr:   stderr,
      command:  command.join(' ')
    )
  end
  begin
    JSON.parse(stdout)
  rescue JSON::ParserError => e
    raise Error.new(
      message:   "Error parsing JSON output from '#{command.first(4).join(' ')}'.",
      exception: e
    )
  end
end

.openbolt_optionsObject



104
105
106
# File 'lib/smart_proxy_openbolt/main.rb', line 104

def openbolt_options
  SORTED_OPTIONS
end

.reload_tasksObject



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/smart_proxy_openbolt/main.rb', line 132

def reload_tasks
  task_data = {}

  # Get a list of all tasks
  command = ['bolt', 'task', 'show',
             '--project', Plugin.settings.environment_path,
             '--format', 'json']
  parsed = openbolt_json(command)
  task_list = parsed['tasks']
  unless task_list.is_a?(Array)
    raise Error.new(
      message: "Unexpected output from 'bolt task show': expected 'tasks' to be an array, got #{task_list.class}."
    )
  end

  # Get metadata for each task
  task_list.each do |task_entry|
    name = task_entry[0]
    command = ['bolt', 'task', 'show', name,
               '--project', Plugin.settings.environment_path,
               '--format', 'json']
    result = openbolt_json(command)
     = result['metadata']
    if .nil?
      raise Error.new(
        message: "Invalid metadata found for task #{name}"
      )
    end

    task_data[name] = {
      'description' => ['description'] || '',
      'parameters'  => ['parameters'] || {},
    }
  end
  @tasks = task_data
end

.result_file_path(id) ⇒ Object



117
118
119
# File 'lib/smart_proxy_openbolt/main.rb', line 117

def result_file_path(id)
  File.join(Plugin.settings.log_dir, "#{id}.json")
end

.scrub(options, text) ⇒ Object

Used only for display text that may contain sensitive OpenBolt options values. Should not be used to pass anything to the CLI.



357
358
359
360
361
362
363
364
365
# File 'lib/smart_proxy_openbolt/main.rb', line 357

def scrub(options, text)
  sensitive = options.select { |key, _| OPENBOLT_OPTIONS[key] && OPENBOLT_OPTIONS[key][:sensitive] }
  sensitive.each_value do |value|
    redact = value.to_s
    next if redact.empty?
    text = text.gsub(redact, '*****')
  end
  text
end

.tasks(reload: false) ⇒ Object

/tasks or /tasks/reload



122
123
124
125
126
127
128
129
130
# File 'lib/smart_proxy_openbolt/main.rb', line 122

def tasks(reload: false)
  # If we need to reload, only one instance of the reload
  # should happen at once. Make others wait until it is
  # finished.
  @mutex.synchronize do
    @tasks = nil if reload
    @tasks || reload_tasks
  end
end

.validate_job_id!(id) ⇒ Object

Raises:



112
113
114
115
# File 'lib/smart_proxy_openbolt/main.rb', line 112

def validate_job_id!(id)
  return if /\A[a-f0-9-]+\z/i.match?(id)
  raise Error.new(message: 'Invalid job ID format')
end