smart_proxy_remote_execution_ssh 0.5.3 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abf6517f6a98386e246650454c7deb049b7583c7653040eb63bbdf8e3cc27f0a
4
- data.tar.gz: 12b0aa0d067e45c73ee65286f69d1556f7ae8b1c47624845e1e0a7e96c341e95
3
+ metadata.gz: 1a599d3732d4f6b064a850ae8baced28354980096785a340828533f953f844c4
4
+ data.tar.gz: 160b80ab983f0eb7b987987191b48c19db42d3b76079b0ba6639ec831dccc6f3
5
5
  SHA512:
6
- metadata.gz: f873d27dc9b5ba0b9c10d7d227058618d901a48643ba31e491e1530b34356aa88ac8a197e9731d83a2a535ffa7577ba674578af8b90de3d02f7537d6359063d5
7
- data.tar.gz: d72dfc490d0f71a076885eeabf435a89bca3658ce31a465666725a19e1d0f3c682d51f899922a2b6dbfdbfe8ee8d880fc0b0d0a13ac2536c5ce8f276f64b059b
6
+ metadata.gz: 349961aa474809225d9781bda09687eb23f00db51cca9f758a57e075fa3d19fc07b729569b064887f91bb7f07190152d6ec9cb01e8ccf48df0841e9c3f85d10e
7
+ data.tar.gz: 8ad40859c92f7d1cd089adf0f37bb0aaa8add745cd461c2e1ed5b484d3ae6b856ac0ee53e61c62e89d564a4ba676b22bf5f01618da970986cd60e42591ff6346
@@ -1,5 +1,6 @@
1
1
  require 'mqtt'
2
2
  require 'json'
3
+ require 'time'
3
4
 
4
5
  module Proxy::RemoteExecution::Ssh::Actions
5
6
  class PullScript < Proxy::Dynflow::Action::Runner
@@ -25,7 +26,8 @@ module Proxy::RemoteExecution::Ssh::Actions
25
26
  otp_password = if input[:with_mqtt]
26
27
  ::Proxy::Dynflow::OtpManager.generate_otp(execution_plan_id)
27
28
  end
28
- input[:job_uuid] = job_storage.store_job(host_name, execution_plan_id, run_step_id, input[:script])
29
+
30
+ input[:job_uuid] = job_storage.store_job(host_name, execution_plan_id, run_step_id, input[:script].tr("\r", ''))
29
31
  output[:state] = :ready_for_pickup
30
32
  output[:result] = []
31
33
  mqtt_start(otp_password) if input[:with_mqtt]
@@ -40,9 +42,40 @@ module Proxy::RemoteExecution::Ssh::Actions
40
42
  def process_external_event(event)
41
43
  output[:state] = :running
42
44
  data = event.data
45
+ case data['version']
46
+ when nil
47
+ process_external_unversioned(data)
48
+ when '1'
49
+ process_external_v1(data)
50
+ else
51
+ raise "Unexpected update message version '#{data['version']}'"
52
+ end
53
+ end
54
+
55
+ def process_external_unversioned(payload)
56
+ continuous_output = Proxy::Dynflow::ContinuousOutput.new
57
+ Array(payload['output']).each { |line| continuous_output.add_output(line, payload['type']) } if payload.key?('output')
58
+ exit_code = payload['exit_code'].to_i if payload['exit_code']
59
+ process_update(Proxy::Dynflow::Runner::Update.new(continuous_output, exit_code))
60
+ end
61
+
62
+ def process_external_v1(payload)
43
63
  continuous_output = Proxy::Dynflow::ContinuousOutput.new
44
- Array(data['output']).each { |line| continuous_output.add_output(line, 'stdout') } if data.key?('output')
45
- exit_code = data['exit_code'].to_i if data['exit_code']
64
+ exit_code = nil
65
+
66
+ payload['updates'].each do |update|
67
+ time = Time.parse update['timestamp']
68
+ type = update['type']
69
+ case type
70
+ when 'output'
71
+ continuous_output.add_output(update['content'], update['stream'], time)
72
+ when 'exit'
73
+ exit_code = update['exit_code'].to_i
74
+ else
75
+ raise "Unexpected update type '#{update['type']}'"
76
+ end
77
+ end
78
+
46
79
  process_update(Proxy::Dynflow::Runner::Update.new(continuous_output, exit_code))
47
80
  end
48
81
 
@@ -56,37 +89,52 @@ module Proxy::RemoteExecution::Ssh::Actions
56
89
  # Client was notified or is already running, dealing with this situation
57
90
  # is only supported if mqtt is available
58
91
  # Otherwise we have to wait it out
59
- # TODO
60
- # if input[:with_mqtt]
92
+ mqtt_cancel if input[:with_mqtt]
61
93
  end
62
94
  suspend
63
95
  end
64
96
 
65
97
  def mqtt_start(otp_password)
66
- payload = {
67
- type: 'data',
68
- message_id: SecureRandom.uuid,
69
- version: 1,
70
- sent: DateTime.now.iso8601,
71
- directive: 'foreman',
98
+ payload = mqtt_payload_base.merge(
99
+ content: "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}",
72
100
  metadata: {
101
+ 'event': 'start',
73
102
  'job_uuid': input[:job_uuid],
74
103
  'username': execution_plan_id,
75
104
  'password': otp_password,
76
105
  'return_url': "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}/update",
77
106
  },
78
- content: "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}",
79
- }
107
+ )
80
108
  mqtt_notify payload
81
109
  output[:state] = :notified
82
110
  end
83
111
 
112
+ def mqtt_cancel
113
+ cleanup
114
+ payload = mqtt_payload_base.merge(
115
+ metadata: {
116
+ 'event': 'cancel',
117
+ 'job_uuid': input[:job_uuid]
118
+ }
119
+ )
120
+ mqtt_notify payload
121
+ end
122
+
84
123
  def mqtt_notify(payload)
85
- MQTT::Client.connect(settings.mqtt_broker, settings.mqtt_port) do |c|
124
+ with_mqtt_client do |c|
86
125
  c.publish(mqtt_topic, JSON.dump(payload), false, 1)
87
126
  end
88
127
  end
89
128
 
129
+ def with_mqtt_client(&block)
130
+ MQTT::Client.connect(settings.mqtt_broker, settings.mqtt_port,
131
+ :ssl => settings.mqtt_tls,
132
+ :cert_file => ::Proxy::SETTINGS.foreman_ssl_cert || ::Proxy::SETTINGS.ssl_certificate,
133
+ :key_file => ::Proxy::SETTINGS.foreman_ssl_key || ::Proxy::SETTINGS.ssl_private_key,
134
+ :ca_file => ::Proxy::SETTINGS.foreman_ssl_ca || ::Proxy::SETTINGS.ssl_ca_file,
135
+ &block)
136
+ end
137
+
90
138
  def host_name
91
139
  alternative_names = input.fetch(:alternative_names, {})
92
140
 
@@ -106,5 +154,15 @@ module Proxy::RemoteExecution::Ssh::Actions
106
154
  def job_storage
107
155
  Proxy::RemoteExecution::Ssh.job_storage
108
156
  end
157
+
158
+ def mqtt_payload_base
159
+ {
160
+ type: 'data',
161
+ message_id: SecureRandom.uuid,
162
+ version: 1,
163
+ sent: DateTime.now.iso8601,
164
+ directive: 'foreman'
165
+ }
166
+ end
109
167
  end
110
168
  end
@@ -13,14 +13,16 @@ module Proxy::RemoteExecution
13
13
  File.read(Ssh.public_key_file)
14
14
  end
15
15
 
16
- post "/session" do
17
- do_authorize_any
18
- session = Cockpit::Session.new(env)
19
- unless session.valid?
20
- return [ 400, "Invalid request: /ssh/session requires connection upgrade to 'raw'" ]
16
+ if Proxy::RemoteExecution::Ssh::Plugin.settings.cockpit_integration
17
+ post "/session" do
18
+ do_authorize_any
19
+ session = Cockpit::Session.new(env)
20
+ unless session.valid?
21
+ return [ 400, "Invalid request: /ssh/session requires connection upgrade to 'raw'" ]
22
+ end
23
+ session.hijack!
24
+ 101
21
25
  end
22
- session.hijack!
23
- 101
24
26
  end
25
27
 
26
28
  delete '/known_hosts/:name' do |name|
@@ -1,5 +1,6 @@
1
1
  require 'smart_proxy_remote_execution_ssh/net_ssh_compat'
2
2
  require 'forwardable'
3
+ require 'securerandom'
3
4
 
4
5
  module Proxy::RemoteExecution
5
6
  module Cockpit
@@ -164,9 +165,8 @@ module Proxy::RemoteExecution
164
165
  out_read, out_write = IO.pipe
165
166
  err_read, err_write = IO.pipe
166
167
 
167
- # Force the script runner to initialize its logger
168
- script_runner.logger
169
- pid = spawn(*script_runner.send(:get_args, command), :in => in_read, :out => out_write, :err => err_write)
168
+ connection.establish!
169
+ pid = spawn(*connection.command(command), :in => in_read, :out => out_write, :err => err_write)
170
170
  [in_read, out_write, err_write].each(&:close)
171
171
 
172
172
  send_start
@@ -176,19 +176,22 @@ module Proxy::RemoteExecution
176
176
  in_buf = MiniSSLBufferedSocket.new(in_write)
177
177
 
178
178
  inner_system_ssh_loop out_buf, err_buf, in_buf, pid
179
+ ensure
180
+ connection.disconnect!
179
181
  end
180
182
 
181
183
  def inner_system_ssh_loop(out_buf, err_buf, in_buf, pid)
182
184
  err_buf_raw = ''
183
- readers = [buf_socket, out_buf, err_buf]
184
185
  loop do
186
+ readers = [buf_socket, out_buf, err_buf].reject { |io| io.closed? }
187
+ writers = [buf_socket, in_buf].select { |io| io.pending_writes? }
185
188
  # Prime the sockets for reading
186
- ready_readers, ready_writers = IO.select(readers, [buf_socket, in_buf], nil, 300)
189
+ ready_readers, ready_writers = IO.select(readers, writers)
187
190
  (ready_readers || []).each { |reader| reader.close if reader.fill.zero? }
188
191
 
189
192
  proxy_data(out_buf, in_buf)
190
193
  if buf_socket.closed?
191
- script_runner.close_session
194
+ connection.disconnect!
192
195
  end
193
196
 
194
197
  if out_buf.closed?
@@ -262,10 +265,10 @@ module Proxy::RemoteExecution
262
265
  params["hostname"]
263
266
  end
264
267
 
265
- def script_runner
266
- @script_runner ||= Proxy::RemoteExecution::Ssh::Runners::ScriptRunner.build(
268
+ def connection
269
+ @connection ||= Proxy::RemoteExecution::Ssh::Runners::MultiplexedSSHConnection.new(
267
270
  runner_params,
268
- suspended_action: nil
271
+ logger: logger
269
272
  )
270
273
  end
271
274
 
@@ -278,6 +281,7 @@ module Proxy::RemoteExecution
278
281
  # For compatibility only
279
282
  ret[:script] = nil
280
283
  ret[:hostname] = host
284
+ ret[:id] = SecureRandom.uuid
281
285
  ret
282
286
  end
283
287
  end
@@ -0,0 +1,23 @@
1
+ # lib/command_logging.rb
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ module CommandLogging
5
+ def log_command(command, label: "Running")
6
+ command = command.join(' ')
7
+ label = "#{label}: " if label
8
+ logger.debug("#{label}#{command}")
9
+ end
10
+
11
+ def set_pm_debug_logging(pm, capture: false, user_method: nil)
12
+ callback = proc do |data|
13
+ data.each_line do |line|
14
+ logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
15
+ user_method.on_data(data, pm.stdin) if user_method
16
+ end
17
+ ''
18
+ end
19
+ pm.on_stdout(&callback)
20
+ pm.on_stderr(&callback)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,196 @@
1
+ require 'smart_proxy_remote_execution_ssh/command_logging'
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ class SensitiveString
5
+ def initialize(value, mask: '*****')
6
+ @value = value
7
+ @mask = mask
8
+ end
9
+
10
+ def inspect
11
+ '"' + to_s + '"'
12
+ end
13
+
14
+ def to_s
15
+ @mask
16
+ end
17
+
18
+ def to_str
19
+ @value
20
+ end
21
+ end
22
+
23
+ class AuthenticationMethod
24
+ attr_reader :name
25
+ def initialize(name, prompt: nil, password: nil)
26
+ @name = name
27
+ @prompt = prompt
28
+ @password = password
29
+ end
30
+
31
+ def ssh_command_prefix
32
+ return [] unless @password
33
+
34
+ prompt = ['-P', @prompt] if @prompt
35
+ [{'SSHPASS' => SensitiveString.new(@password)}, '/usr/bin/sshpass', '-e', prompt].compact
36
+ end
37
+
38
+ def ssh_options
39
+ ["-o PreferredAuthentications=#{name}",
40
+ "-o NumberOfPasswordPrompts=#{@password ? 1 : 0}"]
41
+ end
42
+ end
43
+
44
+ class MultiplexedSSHConnection
45
+ include CommandLogging
46
+
47
+ attr_reader :logger
48
+ def initialize(options, logger:)
49
+ @logger = logger
50
+
51
+ @id = options.fetch(:id)
52
+ @host = options.fetch(:hostname)
53
+ @script = options.fetch(:script)
54
+ @ssh_user = options.fetch(:ssh_user, 'root')
55
+ @ssh_port = options.fetch(:ssh_port, 22)
56
+ @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
57
+ @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
58
+ @host_public_key = options.fetch(:host_public_key, nil)
59
+ @verify_host = options.fetch(:verify_host, nil)
60
+ @client_private_key_file = settings.ssh_identity_key_file
61
+
62
+ @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
63
+ @socket_working_dir = options.fetch(:socket_working_dir, settings.socket_working_dir)
64
+ @socket = nil
65
+ end
66
+
67
+ def establish!
68
+ @available_auth_methods ||= available_authentication_methods
69
+ method = @available_auth_methods.find do |method|
70
+ if try_auth_method(method)
71
+ @available_auth_methods.unshift(method).uniq!
72
+ true
73
+ end
74
+ end
75
+ method || raise("Could not establish connection to remote host using any available authentication method, tried #{@available_auth_methods.map(&:name).join(', ')}")
76
+ end
77
+
78
+ def disconnect!
79
+ return unless connected?
80
+
81
+ cmd = command(%w[-O exit])
82
+ log_command(cmd, label: "Closing shared connection")
83
+ pm = Proxy::Dynflow::ProcessManager.new(cmd)
84
+ set_pm_debug_logging(pm)
85
+ pm.run!
86
+ @socket = nil
87
+ end
88
+
89
+ def connected?
90
+ !@socket.nil?
91
+ end
92
+
93
+ def command(cmd)
94
+ raise "Cannot build command to run over multiplexed connection without having an established connection" unless connected?
95
+
96
+ ['/usr/bin/ssh', reuse_ssh_options, cmd].flatten
97
+ end
98
+
99
+ private
100
+
101
+ def try_auth_method(method)
102
+ # running "ssh -f -N" instead of "ssh true" would be cleaner, but ssh
103
+ # does not close its stderr which trips up the process manager which
104
+ # expects all FDs to be closed
105
+
106
+ full_command = [method.ssh_command_prefix, '/usr/bin/ssh', establish_ssh_options, method.ssh_options, @host, 'true'].flatten
107
+ log_command(full_command)
108
+ pm = Proxy::Dynflow::ProcessManager.new(full_command)
109
+ pm.start!
110
+ if pm.status
111
+ raise pm.stderr.to_s
112
+ else
113
+ set_pm_debug_logging(pm)
114
+ pm.stdin.io.close
115
+ pm.run!
116
+ end
117
+
118
+ if pm.status.zero?
119
+ logger.debug("Established connection using authentication method #{method.name}")
120
+ @socket = socket_file
121
+ true
122
+ else
123
+ logger.debug("Failed to establish connection using authentication method #{method.name}")
124
+ false
125
+ end
126
+ end
127
+
128
+ def settings
129
+ Proxy::RemoteExecution::Ssh::Plugin.settings
130
+ end
131
+
132
+ def available_authentication_methods
133
+ methods = []
134
+ methods << AuthenticationMethod.new('password', password: @ssh_password) if @ssh_password
135
+ if verify_key_passphrase
136
+ methods << AuthenticationMethod.new('publickey', password: @key_passphrase, prompt: 'passphrase')
137
+ end
138
+ methods << AuthenticationMethod.new('gssapi-with-mic') if settings[:kerberos_auth]
139
+ raise "There are no available authentication methods" if methods.empty?
140
+ methods
141
+ end
142
+
143
+ def establish_ssh_options
144
+ return @establish_ssh_options if @establish_ssh_options
145
+ ssh_options = []
146
+ ssh_options << "-o User=#{@ssh_user}"
147
+ ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
148
+ ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
149
+ ssh_options << "-o IdentitiesOnly=yes"
150
+ ssh_options << "-o StrictHostKeyChecking=no"
151
+ ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
152
+ ssh_options << "-o LogLevel=#{ssh_log_level(true)}"
153
+ ssh_options << "-o ControlMaster=auto"
154
+ ssh_options << "-o ControlPath=#{socket_file}"
155
+ ssh_options << "-o ControlPersist=yes"
156
+ ssh_options << "-o ProxyCommand=none"
157
+ @establish_ssh_options = ssh_options
158
+ end
159
+
160
+ def reuse_ssh_options
161
+ ["-o", "ControlPath=#{@socket}", "-o", "LogLevel=#{ssh_log_level(false)}", @host]
162
+ end
163
+
164
+ def socket_file
165
+ File.join(@socket_working_dir, @id)
166
+ end
167
+
168
+ def verify_key_passphrase
169
+ command = ['/usr/bin/ssh-keygen', '-y', '-f', File.expand_path(@client_private_key_file)]
170
+ log_command(command, label: "Checking if private key has passphrase")
171
+ pm = Proxy::Dynflow::ProcessManager.new(command)
172
+ pm.start!
173
+
174
+ raise pm.stderr.to_s if pm.status
175
+
176
+ pm.stdin.io.close
177
+ pm.run!
178
+
179
+ if pm.status.zero?
180
+ logger.debug("Private key is not protected with a passphrase")
181
+ @key_passphrase = nil
182
+ else
183
+ logger.debug("Private key is protected with a passphrase")
184
+ end
185
+
186
+ return true if pm.status.zero? || @key_passphrase
187
+
188
+ logger.debug("Private key is protected with a passphrase, but no passphrase was provided")
189
+ false
190
+ end
191
+
192
+ def ssh_log_level(new_connection)
193
+ new_connection ? settings[:ssh_log_level] : 'quiet'
194
+ end
195
+ end
196
+ end
@@ -173,10 +173,14 @@ module Proxy::RemoteExecution
173
173
  output.append(data)
174
174
  end
175
175
 
176
+ def pending_writes?
177
+ output.length.positive?
178
+ end
179
+
176
180
  # Sends as much of the pending output as possible. Returns +true+ if any
177
181
  # data was sent, and +false+ otherwise.
178
182
  def send_pending
179
- if output.length.positive?
183
+ if pending_writes?
180
184
  sent = send(output.to_s, 0)
181
185
  output.consume!(sent)
182
186
  return sent.positive?
@@ -189,7 +193,7 @@ module Proxy::RemoteExecution
189
193
  # buffer is empty.
190
194
  def wait_for_pending_sends
191
195
  send_pending
192
- while output.length.positive?
196
+ while pending_writes?
193
197
  result = IO.select(nil, [self]) || next
194
198
  next unless result[1].any?
195
199
 
@@ -1,7 +1,11 @@
1
1
  module Proxy::RemoteExecution::Ssh
2
2
  class Plugin < Proxy::Plugin
3
3
  SSH_LOG_LEVELS = %w[debug info error fatal].freeze
4
- MODES = %i[ssh async-ssh pull pull-mqtt].freeze
4
+ MODES = %i[ssh ssh-async pull pull-mqtt].freeze
5
+ # Unix domain socket path length is limited to 104 (on some platforms) characters
6
+ # Socket path is composed of custom path (max 49 characters) + job id (37 characters)
7
+ # + offset(17 characters) + null terminator
8
+ SOCKET_PATH_MAX_LENGTH = 49
5
9
 
6
10
  http_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
7
11
  https_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
@@ -11,16 +15,21 @@ module Proxy::RemoteExecution::Ssh
11
15
  :ssh_user => 'root',
12
16
  :remote_working_dir => '/var/tmp',
13
17
  :local_working_dir => '/var/tmp',
18
+ :socket_working_dir => '/var/tmp',
14
19
  :kerberos_auth => false,
20
+ :cockpit_integration => true,
15
21
  # When set to nil, makes REX use the runner's default interval
16
22
  # :runner_refresh_interval => nil,
17
23
  :ssh_log_level => :error,
18
24
  :cleanup_working_dirs => true,
19
25
  # :mqtt_broker => nil,
20
26
  # :mqtt_port => nil,
27
+ # :mqtt_tls => nil,
21
28
  :mode => :ssh
22
29
 
23
- plugin :ssh, Proxy::RemoteExecution::Ssh::VERSION
30
+ capability(proc { 'cockpit' if settings.cockpit_integration })
31
+
32
+ plugin :script, Proxy::RemoteExecution::Ssh::VERSION
24
33
  after_activation do
25
34
  require 'smart_proxy_dynflow'
26
35
  require 'smart_proxy_remote_execution_ssh/version'
@@ -36,13 +36,12 @@ module Proxy::RemoteExecution::Ssh::Runners
36
36
  def initialization_script
37
37
  close_stdin = '</dev/null'
38
38
  close_fds = close_stdin + ' >/dev/null 2>/dev/null'
39
- main_script = "(#{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
39
+ main_script = "(#{@remote_script_wrapper} #{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
40
40
  control_script_finish = "#{@control_script_path} init-script-finish"
41
41
  <<-SCRIPT.gsub(/^ +\| /, '')
42
42
  | export CONTROL_SCRIPT="#{@control_script_path}"
43
- | #{"chown #{@user_method.effective_user} '#{@base_dir}'" if @user_method.cli_command_prefix}
43
+ | #{"chown #{@user_method.effective_user} #{@base_dir}" if @user_method.cli_command_prefix}
44
44
  | #{@user_method.cli_command_prefix} sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
45
- | echo $! > '#{@base_dir}/pid'
46
45
  SCRIPT
47
46
  end
48
47
 
@@ -51,18 +50,23 @@ module Proxy::RemoteExecution::Ssh::Runners
51
50
  end
52
51
 
53
52
  def refresh
54
- err = output = nil
53
+ @connection.establish! unless @connection.connected?
55
54
  begin
56
- _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
55
+ pm = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
56
+ process_retrieved_data(pm.stdout.to_s.chomp, pm.stderr.to_s.chomp)
57
57
  rescue StandardError => e
58
58
  @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
59
59
  end
60
-
61
- process_retrieved_data(output, err)
62
60
  ensure
63
61
  destroy_session
64
62
  end
65
63
 
64
+ def kill
65
+ run_sync("pkill -P $(cat #{@pid_path})")
66
+ rescue StandardError => e
67
+ publish_exception('Unexpected error', e, false)
68
+ end
69
+
66
70
  def process_retrieved_data(output, err)
67
71
  return if output.nil? || output.empty?
68
72
 
@@ -127,11 +131,15 @@ module Proxy::RemoteExecution::Ssh::Runners
127
131
  end
128
132
 
129
133
  def cleanup
130
- run_sync("rm -rf \"#{remote_command_dir}\"") if @cleanup_working_dirs
134
+ if @cleanup_working_dirs
135
+ ensure_remote_command("rm -rf #{remote_command_dir}",
136
+ publish: true,
137
+ error: "Unable to remove working directory #{remote_command_dir} on remote system, exit code: %{exit_code}")
138
+ end
131
139
  end
132
140
 
133
141
  def destroy_session
134
- if @session
142
+ if @connection.connected?
135
143
  @logger.debug("Closing session with #{@ssh_user}@#{@host}")
136
144
  close_session
137
145
  end
@@ -1,5 +1,7 @@
1
1
  require 'fileutils'
2
- require 'smart_proxy_dynflow/runner/command'
2
+ require 'smart_proxy_dynflow/runner/process_manager_command'
3
+ require 'smart_proxy_dynflow/process_manager'
4
+ require 'smart_proxy_remote_execution_ssh/multiplexed_ssh_connection'
3
5
 
4
6
  module Proxy::RemoteExecution::Ssh::Runners
5
7
  class EffectiveUserMethod
@@ -12,9 +14,9 @@ module Proxy::RemoteExecution::Ssh::Runners
12
14
  @password_sent = false
13
15
  end
14
16
 
15
- def on_data(received_data, ssh_channel)
17
+ def on_data(received_data, io_buffer)
16
18
  if received_data.match(login_prompt)
17
- ssh_channel.puts(effective_user_password)
19
+ io_buffer.add_data(effective_user_password + "\n")
18
20
  @password_sent = true
19
21
  end
20
22
  end
@@ -90,7 +92,9 @@ module Proxy::RemoteExecution::Ssh::Runners
90
92
 
91
93
  # rubocop:disable Metrics/ClassLength
92
94
  class ScriptRunner < Proxy::Dynflow::Runner::Base
93
- include Proxy::Dynflow::Runner::Command
95
+ include Proxy::Dynflow::Runner::ProcessManagerCommand
96
+ include CommandLogging
97
+
94
98
  attr_reader :execution_timeout_interval
95
99
 
96
100
  EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
@@ -102,18 +106,17 @@ module Proxy::RemoteExecution::Ssh::Runners
102
106
  @script = options.fetch(:script)
103
107
  @ssh_user = options.fetch(:ssh_user, 'root')
104
108
  @ssh_port = options.fetch(:ssh_port, 22)
105
- @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
106
- @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
107
109
  @host_public_key = options.fetch(:host_public_key, nil)
108
- @verify_host = options.fetch(:verify_host, nil)
109
110
  @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
110
111
 
111
112
  @client_private_key_file = settings.ssh_identity_key_file
112
113
  @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
113
- @remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir)
114
+ @remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir.shellescape)
115
+ @socket_working_dir = options.fetch(:socket_working_dir, settings.socket_working_dir)
114
116
  @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
115
117
  @first_execution = options.fetch(:first_execution, false)
116
118
  @user_method = user_method
119
+ @options = options
117
120
  end
118
121
 
119
122
  def self.build(options, suspended_action:)
@@ -141,6 +144,10 @@ module Proxy::RemoteExecution::Ssh::Runners
141
144
 
142
145
  def start
143
146
  Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
147
+ ensure_local_directory(@socket_working_dir)
148
+ @connection = MultiplexedSSHConnection.new(@options.merge(:id => @id), logger: logger)
149
+ @connection.establish!
150
+ preflight_checks
144
151
  prepare_start
145
152
  script = initialization_script
146
153
  logger.debug("executing script:\n#{indent_multiline(script)}")
@@ -154,32 +161,51 @@ module Proxy::RemoteExecution::Ssh::Runners
154
161
  run_async(*args)
155
162
  end
156
163
 
164
+ def preflight_checks
165
+ ensure_remote_command(cp_script_to_remote("#!/bin/sh\nexec true", 'test'),
166
+ error: 'Failed to execute script on remote machine, exit code: %{exit_code}.'
167
+ )
168
+ unless @user_method.is_a? NoopUserMethod
169
+ path = cp_script_to_remote("#!/bin/sh\nexec #{@user_method.cli_command_prefix} true", 'effective-user-test')
170
+ ensure_remote_command(path,
171
+ error: 'Failed to change to effective user, exit code: %{exit_code}',
172
+ tty: true,
173
+ user_method: @user_method,
174
+ close_stdin: false)
175
+ end
176
+ end
177
+
157
178
  def prepare_start
158
179
  @remote_script = cp_script_to_remote
159
180
  @output_path = File.join(File.dirname(@remote_script), 'output')
160
181
  @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
182
+ @pid_path = File.join(File.dirname(@remote_script), 'pid')
183
+ @remote_script_wrapper = upload_data("echo $$ > #{@pid_path}; exec \"$@\";", File.join(File.dirname(@remote_script), 'script-wrapper'), 555)
161
184
  end
162
185
 
163
186
  # the script that initiates the execution
164
187
  def initialization_script
165
188
  su_method = @user_method.instance_of?(SuUserMethod)
166
189
  # pipe the output to tee while capturing the exit code in a file
167
- <<-SCRIPT.gsub(/^\s+\| /, '')
168
- | sh -c "(#{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}; echo \\$?>#{@exit_code_path}) | /usr/bin/tee #{@output_path}
169
- | exit \\$(cat #{@exit_code_path})"
190
+ <<~SCRIPT
191
+ sh <<EOF | /usr/bin/tee #{@output_path}
192
+ #{@remote_script_wrapper} #{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}
193
+ echo \\$?>#{@exit_code_path}
194
+ EOF
195
+ exit $(cat #{@exit_code_path})
170
196
  SCRIPT
171
197
  end
172
198
 
173
199
  def refresh
174
- return if @session.nil?
200
+ return if @process_manager.nil?
175
201
  super
176
202
  ensure
177
203
  check_expecting_disconnect
178
204
  end
179
205
 
180
206
  def kill
181
- if @session
182
- run_sync("pkill -f #{remote_command_file('script')}")
207
+ if @process_manager&.started?
208
+ run_sync("pkill -P $(cat #{@pid_path})")
183
209
  else
184
210
  logger.debug('connection closed')
185
211
  end
@@ -197,28 +223,24 @@ module Proxy::RemoteExecution::Ssh::Runners
197
223
  end
198
224
 
199
225
  def close_session
200
- @session = nil
201
- raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
226
+ raise 'Control socket file does not exist' unless File.exist?(socket_file)
202
227
  @logger.debug("Sending exit request for session #{@ssh_user}@#{@host}")
203
- args = ['/usr/bin/ssh', @host, "-o", "User=#{@ssh_user}", "-o", "ControlPath=#{local_command_file("socket")}", "-O", "exit"].flatten
204
- pid, *, err = session(args, in_stream: false, out_stream: false)
205
- result = read_output_debug(err)
206
- Process.wait(pid)
207
- result
228
+ @connection.disconnect!
208
229
  end
209
230
 
210
231
  def close
211
- run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
232
+ run_sync("rm -rf #{remote_command_dir}") if should_cleanup?
212
233
  rescue StandardError => e
213
234
  publish_exception('Error when removing remote working dir', e, false)
214
235
  ensure
215
- close_session if @session
236
+ close_session if @process_manager
216
237
  FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
217
238
  end
218
239
 
219
- def publish_data(data, type)
240
+ def publish_data(data, type, pm = nil)
241
+ pm ||= @process_manager
220
242
  super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
221
- @user_method.on_data(data, @command_in)
243
+ @user_method.on_data(data, pm.stdin) if pm
222
244
  end
223
245
 
224
246
  private
@@ -228,94 +250,47 @@ module Proxy::RemoteExecution::Ssh::Runners
228
250
  end
229
251
 
230
252
  def should_cleanup?
231
- @session && @cleanup_working_dirs
232
- end
233
-
234
- # Creates session with three pipes - one for reading and two for
235
- # writing. Similar to `Open3.popen3` method but without creating
236
- # a separate thread to monitor it.
237
- def session(args, in_stream: true, out_stream: true, err_stream: true)
238
- @session = true
239
-
240
- in_read, in_write = in_stream ? IO.pipe : '/dev/null'
241
- out_read, out_write = out_stream ? IO.pipe : [nil, '/dev/null']
242
- err_read, err_write = err_stream ? IO.pipe : [nil, '/dev/null']
243
- command_pid = spawn(*args, :in => in_read, :out => out_write, :err => err_write)
244
- in_read.close if in_stream
245
- out_write.close if out_stream
246
- err_write.close if err_stream
247
-
248
- return command_pid, in_write, out_read, err_read
249
- end
250
-
251
- def ssh_options(with_pty = false)
252
- ssh_options = []
253
- ssh_options << "-tt" if with_pty
254
- ssh_options << "-o User=#{@ssh_user}"
255
- ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
256
- ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
257
- ssh_options << "-o IdentitiesOnly=yes"
258
- ssh_options << "-o StrictHostKeyChecking=no"
259
- ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
260
- ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
261
- ssh_options << "-o NumberOfPasswordPrompts=1"
262
- ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
263
- ssh_options << "-o ControlMaster=auto"
264
- ssh_options << "-o ControlPath=#{local_command_file("socket")}"
265
- ssh_options << "-o ControlPersist=yes"
253
+ @process_manager && @cleanup_working_dirs
266
254
  end
267
255
 
268
256
  def settings
269
257
  Proxy::RemoteExecution::Ssh::Plugin.settings
270
258
  end
271
259
 
272
- def get_args(command, with_pty = false)
273
- args = []
274
- args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
275
- args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
276
- args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
277
- end
278
-
279
260
  # Initiates run of the remote command and yields the data when
280
261
  # available. The yielding doesn't happen automatically, but as
281
262
  # part of calling the `refresh` method.
282
263
  def run_async(command)
283
- raise 'Async command already in progress' if @started
264
+ raise 'Async command already in progress' if @process_manager&.started?
284
265
 
285
- @started = false
286
266
  @user_method.reset
287
- @command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
288
- @started = true
267
+ cmd = @connection.command([tty_flag(true), command].flatten.compact)
268
+ log_command(cmd)
269
+ initialize_command(*cmd)
289
270
 
290
- return true
271
+ true
291
272
  end
292
273
 
293
274
  def run_started?
294
- @started && @user_method.sent_all_data?
275
+ @process_manager&.started? && @user_method.sent_all_data?
295
276
  end
296
277
 
297
- def read_output_debug(err_io, out_io = nil)
298
- stdout = ''
299
- debug_str = ''
300
-
301
- if out_io
302
- stdout += out_io.read until out_io.eof? rescue
303
- out_io.close
304
- end
305
- debug_str += err_io.read until err_io.eof? rescue
306
- err_io.close
307
- debug_str.lines.each { |line| @logger.debug(line.strip) }
308
-
309
- return stdout, debug_str
278
+ def tty_flag(tty)
279
+ '-tt' if tty
310
280
  end
311
281
 
312
- def run_sync(command, stdin = nil)
313
- pid, tx, rx, err = session(get_args(command))
314
- tx.puts(stdin) unless stdin.nil?
315
- tx.close
316
- stdout, stderr = read_output_debug(err, rx)
317
- exit_status = Process.wait2(pid)[1].exitstatus
318
- return exit_status, stdout, stderr
282
+ def run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil)
283
+ cmd = @connection.command([tty_flag(tty), command].flatten.compact)
284
+ log_command(cmd)
285
+ pm = Proxy::Dynflow::ProcessManager.new(cmd)
286
+ set_pm_debug_logging(pm, user_method: user_method)
287
+ pm.start!
288
+ unless pm.status
289
+ pm.stdin.io.puts(stdin) if stdin
290
+ pm.stdin.io.close if close_stdin
291
+ pm.run!
292
+ end
293
+ pm
319
294
  end
320
295
 
321
296
  def prepare_known_hosts
@@ -334,6 +309,10 @@ module Proxy::RemoteExecution::Ssh::Runners
334
309
  File.join(ensure_local_directory(local_command_dir), filename)
335
310
  end
336
311
 
312
+ def socket_file
313
+ File.join(ensure_local_directory(@socket_working_dir), @id)
314
+ end
315
+
337
316
  def remote_command_dir
338
317
  File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
339
318
  end
@@ -362,15 +341,13 @@ module Proxy::RemoteExecution::Ssh::Runners
362
341
  # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
363
342
  # This is used to write to $path with elevated permissions, solutions using cat and output redirection
364
343
  # would not work, because the redirection would happen in the non-elevated shell.
365
- command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
344
+ command = "tee #{path} >/dev/null && chmod #{permissions} #{path}"
366
345
 
367
346
  @logger.debug("Sending data to #{path} on remote host:\n#{data}")
368
- status, _out, err = run_sync(command, data)
369
-
370
- @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
371
- if status != 0
372
- raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
373
- end
347
+ ensure_remote_command(command,
348
+ stdin: data,
349
+ error: "Unable to upload file to #{path} on remote system, exit code: %{exit_code}"
350
+ )
374
351
 
375
352
  path
376
353
  end
@@ -382,9 +359,15 @@ module Proxy::RemoteExecution::Ssh::Runners
382
359
  end
383
360
 
384
361
  def ensure_remote_directory(path)
385
- exit_code, _output, err = run_sync("mkdir -p #{path}")
386
- if exit_code != 0
387
- raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
362
+ ensure_remote_command("mkdir -p #{path}",
363
+ error: "Unable to create directory #{path} on remote system, exit code: %{exit_code}"
364
+ )
365
+ end
366
+
367
+ def ensure_remote_command(cmd, error: nil, **kwargs)
368
+ if (pm = run_sync(cmd, **kwargs)).status != 0
369
+ msg = error || 'Failed to run command %{command} on remote machine, exit code: %{exit_code}'
370
+ raise(msg % { command: cmd, exit_code: pm.status })
388
371
  end
389
372
  end
390
373
 
@@ -410,13 +393,6 @@ module Proxy::RemoteExecution::Ssh::Runners
410
393
  @expecting_disconnect = true
411
394
  end
412
395
  end
413
-
414
- def available_authentication_methods
415
- methods = %w[publickey] # Always use pubkey auth as fallback
416
- methods << 'gssapi-with-mic' if settings[:kerberos_auth]
417
- methods.unshift('password') if @ssh_password
418
- methods
419
- end
420
396
  end
421
397
  # rubocop:enable Metrics/ClassLength
422
398
  end
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.5.3'
4
+ VERSION = '0.8.0'
5
5
  end
6
6
  end
7
7
  end
@@ -7,22 +7,10 @@ module Proxy::RemoteExecution
7
7
  module Ssh
8
8
  class << self
9
9
  def validate!
10
- unless private_key_file
11
- raise "settings for `ssh_identity_key` not set"
12
- end
13
-
14
- unless File.exist?(private_key_file)
15
- raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
16
- "You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
17
- end
18
-
19
- unless File.exist?(public_key_file)
20
- raise "Ssh public key file #{public_key_file} doesn't exist"
21
- end
22
-
23
10
  validate_mode!
24
- validate_ssh_log_level!
11
+ validate_ssh_settings!
25
12
  validate_mqtt_settings!
13
+ validate_socket_path!
26
14
  end
27
15
 
28
16
  def private_key_file
@@ -60,6 +48,28 @@ module Proxy::RemoteExecution
60
48
 
61
49
  raise 'mqtt_broker has to be set when pull-mqtt mode is used' if Plugin.settings.mqtt_broker.nil?
62
50
  raise 'mqtt_port has to be set when pull-mqtt mode is used' if Plugin.settings.mqtt_port.nil?
51
+
52
+ if Plugin.settings.mqtt_tls.nil?
53
+ Plugin.settings.mqtt_tls = [[:foreman_ssl_cert, :ssl_certificate], [:foreman_ssl_key, :ssl_private_key], [:foreman_ssl_ca, :ssl_ca_file]].all? { |(client, server)| ::Proxy::SETTINGS[client] || ::Proxy::SETTINGS[server] }
54
+ end
55
+ end
56
+
57
+ def validate_ssh_settings!
58
+ return unless requires_configured_ssh?
59
+ unless private_key_file
60
+ raise "settings for `ssh_identity_key` not set"
61
+ end
62
+
63
+ unless File.exist?(private_key_file)
64
+ raise "SSH public key file #{private_key_file} doesn't exist.\n"\
65
+ "You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
66
+ end
67
+
68
+ unless File.exist?(public_key_file)
69
+ raise "SSH public key file #{public_key_file} doesn't exist"
70
+ end
71
+
72
+ validate_ssh_log_level!
63
73
  end
64
74
 
65
75
  def validate_ssh_log_level!
@@ -83,6 +93,17 @@ module Proxy::RemoteExecution
83
93
  Plugin.settings.ssh_log_level = Plugin.settings.ssh_log_level.to_sym
84
94
  end
85
95
 
96
+ def requires_configured_ssh?
97
+ %i[ssh ssh-async].include?(Plugin.settings.mode) || Plugin.settings.cockpit_integration
98
+ end
99
+
100
+ def validate_socket_path!
101
+ return unless Plugin.settings.mode == :'ssh' || Plugin.settings.mode == :'ssh-async'
102
+
103
+ socket_path = File.expand_path(Plugin.settings.socket_working_dir)
104
+ raise "Socket path #{socket_path} is too long" if socket_path.length > Plugin::SOCKET_PATH_MAX_LENGTH
105
+ end
106
+
86
107
  def job_storage
87
108
  @job_storage ||= Proxy::RemoteExecution::Ssh::JobStorage.new
88
109
  end
@@ -3,8 +3,11 @@
3
3
  :ssh_identity_key_file: '~/.ssh/id_rsa_foreman_proxy'
4
4
  :local_working_dir: '/var/tmp'
5
5
  :remote_working_dir: '/var/tmp'
6
+ :socket_working_dir: '/var/tmp'
6
7
  # :kerberos_auth: false
7
8
 
9
+ # :cockpit_integration: true
10
+
8
11
  # Mode of operation, one of ssh, ssh-async, pull, pull-mqtt
9
12
  :mode: ssh
10
13
 
@@ -24,3 +27,8 @@
24
27
  # MQTT configuration, need to be set if mode is set to pull-mqtt
25
28
  # :mqtt_broker: localhost
26
29
  # :mqtt_port: 1883
30
+
31
+ # Use of SSL can be forced either way by explicitly setting mqtt_tls setting. If
32
+ # unset, SSL gets used if smart-proxy's foreman_ssl_cert, foreman_ssl_key and
33
+ # foreman_ssl_ca settings are set available.
34
+ # :mqtt_tls:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_proxy_remote_execution_ssh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Nečas
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-04-04 00:00:00.000000000 Z
11
+ date: 1980-01-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -114,14 +114,14 @@ dependencies:
114
114
  requirements:
115
115
  - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: '0.5'
117
+ version: '0.8'
118
118
  type: :runtime
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: '0.5'
124
+ version: '0.8'
125
125
  - !ruby/object:Gem::Dependency
126
126
  name: net-ssh
127
127
  requirement: !ruby/object:Gem::Requirement
@@ -170,10 +170,12 @@ files:
170
170
  - lib/smart_proxy_remote_execution_ssh/async_scripts/control.sh
171
171
  - lib/smart_proxy_remote_execution_ssh/async_scripts/retrieve.sh
172
172
  - lib/smart_proxy_remote_execution_ssh/cockpit.rb
173
+ - lib/smart_proxy_remote_execution_ssh/command_logging.rb
173
174
  - lib/smart_proxy_remote_execution_ssh/dispatcher.rb
174
175
  - lib/smart_proxy_remote_execution_ssh/http_config.ru
175
176
  - lib/smart_proxy_remote_execution_ssh/job_storage.rb
176
177
  - lib/smart_proxy_remote_execution_ssh/log_filter.rb
178
+ - lib/smart_proxy_remote_execution_ssh/multiplexed_ssh_connection.rb
177
179
  - lib/smart_proxy_remote_execution_ssh/net_ssh_compat.rb
178
180
  - lib/smart_proxy_remote_execution_ssh/plugin.rb
179
181
  - lib/smart_proxy_remote_execution_ssh/runners.rb
@@ -203,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
203
205
  - !ruby/object:Gem::Version
204
206
  version: '0'
205
207
  requirements: []
206
- rubygems_version: 3.1.4
208
+ rubygems_version: 3.2.26
207
209
  signing_key:
208
210
  specification_version: 4
209
211
  summary: Ssh remote execution provider for Foreman Smart-Proxy