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 relevantDefaults set here are in case the UI does not send any information for
the key, and should only be present if this value is requiredSensitive 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
-
.delete_artifacts(id) ⇒ Object
DELETE /job/:id/artifacts.
- .executor ⇒ Object
-
.get_result(id) ⇒ Object
/job/:id/result.
-
.get_status(id) ⇒ Object
/job/:id/status.
-
.launch_task(data) ⇒ Object
/launch/task.
-
.normalize_values(hash) ⇒ Object
Normalize options and parameters, since the UI may send unspecified options as empty strings.
-
.openbolt(command) ⇒ Object
Anything that needs to run an OpenBolt CLI command should use this.
-
.openbolt_json(command) ⇒ Object
Runs an openbolt command that is expected to produce JSON on stdout.
- .openbolt_options ⇒ Object
- .reload_tasks ⇒ Object
- .result_file_path(id) ⇒ Object
-
.scrub(options, text) ⇒ Object
Used only for display text that may contain sensitive OpenBolt options values.
-
.tasks(reload: false) ⇒ Object
/tasks or /tasks/reload.
- .validate_job_id!(id) ⇒ Object
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 |
.executor ⇒ Object
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
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'] = data['options'] || {} logger.info("Task: #{name}") logger.info("Parameters: #{params.inspect}") logger.info("Targets: #{targets.inspect}") logger.info("Options: #{scrub(, .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 .is_a?(Hash) unknown = .keys - OPENBOLT_OPTIONS.keys raise Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty? # Normalize options, removing blank values = normalize_values() logger.info("Normalized options: #{scrub(, .inspect)}") OPENBOLT_OPTIONS.each { |key, value| [key] ||= value[:default] if value.key?(:default) } logger.info("Options with required defaults: #{scrub(, .inspect)}") # Validate option types = .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(, .inspect)}") ### Run the task ### task = TaskJob.new(name, params, , 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_options ⇒ Object
104 105 106 |
# File 'lib/smart_proxy_openbolt/main.rb', line 104 def SORTED_OPTIONS end |
.reload_tasks ⇒ Object
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(, text) sensitive = .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 |