smart_proxy_remote_execution_ssh 0.3.2 → 0.5.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,87 @@
1
+ module Proxy::RemoteExecution::Ssh::Runners
2
+ class FakeScriptRunner < ::Proxy::Dynflow::Runner::Base
3
+ DEFAULT_REFRESH_INTERVAL = 1
4
+
5
+ @data = []
6
+
7
+ class << self
8
+ attr_accessor :data
9
+
10
+ def load_data(path = nil)
11
+ if path.nil?
12
+ @data = <<-BANNER.gsub(/^\s+\| ?/, '').lines
13
+ | ====== Simulated Remote Execution ======
14
+ |
15
+ | This is an output of a simulated remote
16
+ | execution run. It should run for about
17
+ | 5 seconds and finish successfully.
18
+ BANNER
19
+ else
20
+ File.open(File.expand_path(path), 'r') do |f|
21
+ @data = f.readlines.map(&:chomp)
22
+ end
23
+ end
24
+ @data.freeze
25
+ end
26
+
27
+ def build(options, suspended_action:)
28
+ new(options, suspended_action: suspended_action)
29
+ end
30
+ end
31
+
32
+ def initialize(*args)
33
+ super
34
+ # Load the fake output the first time its needed
35
+ self.class.load_data(ENV['REX_SIMULATE_PATH']) unless self.class.data.frozen?
36
+ @position = 0
37
+ end
38
+
39
+ def start
40
+ refresh
41
+ end
42
+
43
+ # Do one step
44
+ def refresh
45
+ if done?
46
+ finish
47
+ else
48
+ step
49
+ end
50
+ end
51
+
52
+ def kill
53
+ finish
54
+ end
55
+
56
+ private
57
+
58
+ def finish
59
+ publish_exit_status exit_code
60
+ end
61
+
62
+ def step
63
+ publish_data(next_chunk, 'stdout')
64
+ end
65
+
66
+ def done?
67
+ @position == self.class.data.count
68
+ end
69
+
70
+ def next_chunk
71
+ output = self.class.data[@position]
72
+ @position += 1
73
+ output
74
+ end
75
+
76
+ # Decide if the execution should fail or not
77
+ def exit_code
78
+ fail_chance = ENV.fetch('REX_SIMULATE_FAIL_CHANCE', 0).to_i
79
+ fail_exitcode = ENV.fetch('REX_SIMULATE_EXIT', 0).to_i
80
+ if fail_exitcode.zero? || fail_chance < (Random.rand * 100).round
81
+ 0
82
+ else
83
+ fail_exitcode
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,139 @@
1
+ require 'base64'
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ class PollingScriptRunner < ScriptRunner
5
+ DEFAULT_REFRESH_INTERVAL = 60
6
+
7
+ def self.load_script(name)
8
+ script_dir = File.expand_path('../async_scripts', __dir__)
9
+ File.read(File.join(script_dir, name))
10
+ end
11
+
12
+ # The script that controls the flow of the job, able to initiate update or
13
+ # finish on the task, or take over the control over script lifecycle
14
+ CONTROL_SCRIPT = load_script('control.sh')
15
+
16
+ # The script always outputs at least one line
17
+ # First line of the output either has to begin with
18
+ # "RUNNING" or "DONE $EXITCODE"
19
+ # The following lines are treated as regular output
20
+ RETRIEVE_SCRIPT = load_script('retrieve.sh')
21
+
22
+ def initialize(options, user_method, suspended_action: nil)
23
+ super(options, user_method, suspended_action: suspended_action)
24
+ @callback_host = options[:callback_host]
25
+ @task_id = options[:uuid]
26
+ @step_id = options[:step_id]
27
+ @otp = Proxy::Dynflow::OtpManager.generate_otp(@task_id)
28
+ end
29
+
30
+ def prepare_start
31
+ super
32
+ @base_dir = File.dirname @remote_script
33
+ upload_control_scripts
34
+ end
35
+
36
+ def initialization_script
37
+ close_stdin = '</dev/null'
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"
40
+ control_script_finish = "#{@control_script_path} init-script-finish"
41
+ <<-SCRIPT.gsub(/^ +\| /, '')
42
+ | export CONTROL_SCRIPT="#{@control_script_path}"
43
+ | sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
44
+ | echo $! > '#{@base_dir}/pid'
45
+ SCRIPT
46
+ end
47
+
48
+ def trigger(*args)
49
+ run_sync(*args)
50
+ end
51
+
52
+ def refresh
53
+ err = output = nil
54
+ begin
55
+ _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
56
+ rescue StandardError => e
57
+ @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
58
+ end
59
+
60
+ process_retrieved_data(output, err)
61
+ ensure
62
+ destroy_session
63
+ end
64
+
65
+ def process_retrieved_data(output, err)
66
+ return if output.nil? || output.empty?
67
+
68
+ lines = output.lines
69
+ result = lines.shift.match(/^DONE (\d+)?/)
70
+ publish_data(lines.join, 'stdout') unless lines.empty?
71
+ publish_data(err, 'stderr') unless err.empty?
72
+ if result
73
+ exitcode = result[1] || 0
74
+ publish_exit_status(exitcode.to_i)
75
+ cleanup
76
+ end
77
+ end
78
+
79
+ def external_event(event)
80
+ data = event.data
81
+ if data['manual_mode']
82
+ load_event_updates(data)
83
+ else
84
+ # getting the update from automatic mode - reaching to the host to get the latest update
85
+ return run_refresh
86
+ end
87
+ ensure
88
+ destroy_session
89
+ end
90
+
91
+ def close
92
+ super
93
+ Proxy::Dynflow::OtpManager.drop_otp(@task_id, @otp) if @otp
94
+ end
95
+
96
+ def upload_control_scripts
97
+ return if @control_scripts_uploaded
98
+
99
+ cp_script_to_remote(env_script, 'env.sh')
100
+ @control_script_path = cp_script_to_remote(CONTROL_SCRIPT, 'control.sh')
101
+ @retrieval_script = cp_script_to_remote(RETRIEVE_SCRIPT, 'retrieve.sh')
102
+ @control_scripts_uploaded = true
103
+ end
104
+
105
+ # Script setting the dynamic values to env variables: it's sourced from other control scripts
106
+ def env_script
107
+ <<-SCRIPT.gsub(/^ +\| /, '')
108
+ | CALLBACK_HOST="#{@callback_host}"
109
+ | TASK_ID="#{@task_id}"
110
+ | STEP_ID="#{@step_id}"
111
+ | OTP="#{@otp}"
112
+ SCRIPT
113
+ end
114
+
115
+ private
116
+
117
+ # Generates updates based on the callback data from the manual mode
118
+ def load_event_updates(event_data)
119
+ continuous_output = Proxy::Dynflow::ContinuousOutput.new
120
+ if event_data.key?('output')
121
+ lines = Base64.decode64(event_data['output']).sub(/\A(RUNNING|DONE).*\n/, '')
122
+ continuous_output.add_output(lines, 'stdout')
123
+ end
124
+ cleanup if event_data['exit_code']
125
+ new_update(continuous_output, event_data['exit_code'])
126
+ end
127
+
128
+ def cleanup
129
+ run_sync("rm -rf \"#{remote_command_dir}\"") if @cleanup_working_dirs
130
+ end
131
+
132
+ def destroy_session
133
+ if @session
134
+ @logger.debug("Closing session with #{@ssh_user}@#{@host}")
135
+ close_session
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,420 @@
1
+ require 'fileutils'
2
+ require 'smart_proxy_dynflow/runner/command'
3
+
4
+ module Proxy::RemoteExecution::Ssh::Runners
5
+ class EffectiveUserMethod
6
+ attr_reader :effective_user, :ssh_user, :effective_user_password, :password_sent
7
+
8
+ def initialize(effective_user, ssh_user, effective_user_password)
9
+ @effective_user = effective_user
10
+ @ssh_user = ssh_user
11
+ @effective_user_password = effective_user_password.to_s
12
+ @password_sent = false
13
+ end
14
+
15
+ def on_data(received_data, ssh_channel)
16
+ if received_data.match(login_prompt)
17
+ ssh_channel.puts(effective_user_password)
18
+ @password_sent = true
19
+ end
20
+ end
21
+
22
+ def filter_password?(received_data)
23
+ !@effective_user_password.empty? && @password_sent && received_data.match(Regexp.escape(@effective_user_password))
24
+ end
25
+
26
+ def sent_all_data?
27
+ effective_user_password.empty? || password_sent
28
+ end
29
+
30
+ def reset
31
+ @password_sent = false
32
+ end
33
+
34
+ def cli_command_prefix; end
35
+
36
+ def login_prompt; end
37
+ end
38
+
39
+ class SudoUserMethod < EffectiveUserMethod
40
+ LOGIN_PROMPT = 'rex login: '.freeze
41
+
42
+ def login_prompt
43
+ LOGIN_PROMPT
44
+ end
45
+
46
+ def cli_command_prefix
47
+ "sudo -p '#{LOGIN_PROMPT}' -u #{effective_user} "
48
+ end
49
+ end
50
+
51
+ class DzdoUserMethod < EffectiveUserMethod
52
+ LOGIN_PROMPT = /password/i.freeze
53
+
54
+ def login_prompt
55
+ LOGIN_PROMPT
56
+ end
57
+
58
+ def cli_command_prefix
59
+ "dzdo -u #{effective_user} "
60
+ end
61
+ end
62
+
63
+ class SuUserMethod < EffectiveUserMethod
64
+ LOGIN_PROMPT = /Password: /i.freeze
65
+
66
+ def login_prompt
67
+ LOGIN_PROMPT
68
+ end
69
+
70
+ def cli_command_prefix
71
+ "su - #{effective_user} -c "
72
+ end
73
+ end
74
+
75
+ class NoopUserMethod
76
+ def on_data(_, _); end
77
+
78
+ def filter_password?(received_data)
79
+ false
80
+ end
81
+
82
+ def sent_all_data?
83
+ true
84
+ end
85
+
86
+ def cli_command_prefix; end
87
+
88
+ def reset; end
89
+ end
90
+
91
+ # rubocop:disable Metrics/ClassLength
92
+ class ScriptRunner < Proxy::Dynflow::Runner::Base
93
+ include Proxy::Dynflow::Runner::Command
94
+ attr_reader :execution_timeout_interval
95
+
96
+ EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
97
+ DEFAULT_REFRESH_INTERVAL = 1
98
+
99
+ def initialize(options, user_method, suspended_action: nil)
100
+ super suspended_action: suspended_action
101
+ @host = options.fetch(:hostname)
102
+ @script = options.fetch(:script)
103
+ @ssh_user = options.fetch(:ssh_user, 'root')
104
+ @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
+ @host_public_key = options.fetch(:host_public_key, nil)
108
+ @verify_host = options.fetch(:verify_host, nil)
109
+ @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
110
+
111
+ @client_private_key_file = settings.ssh_identity_key_file
112
+ @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
+ @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
115
+ @first_execution = options.fetch(:first_execution, false)
116
+ @user_method = user_method
117
+ end
118
+
119
+ def self.build(options, suspended_action:)
120
+ effective_user = options.fetch(:effective_user, nil)
121
+ ssh_user = options.fetch(:ssh_user, 'root')
122
+ effective_user_method = options.fetch(:effective_user_method, 'sudo')
123
+
124
+ user_method = if effective_user.nil? || effective_user == ssh_user
125
+ NoopUserMethod.new
126
+ elsif effective_user_method == 'sudo'
127
+ SudoUserMethod.new(effective_user, ssh_user,
128
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
129
+ elsif effective_user_method == 'dzdo'
130
+ DzdoUserMethod.new(effective_user, ssh_user,
131
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
132
+ elsif effective_user_method == 'su'
133
+ SuUserMethod.new(effective_user, ssh_user,
134
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
135
+ else
136
+ raise "effective_user_method '#{effective_user_method}' not supported"
137
+ end
138
+
139
+ new(options, user_method, suspended_action: suspended_action)
140
+ end
141
+
142
+ def start
143
+ Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
144
+ prepare_start
145
+ script = initialization_script
146
+ logger.debug("executing script:\n#{indent_multiline(script)}")
147
+ trigger(script)
148
+ rescue StandardError, NotImplementedError => e
149
+ logger.error("error while initializing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
150
+ publish_exception('Error initializing command', e)
151
+ end
152
+
153
+ def trigger(*args)
154
+ run_async(*args)
155
+ end
156
+
157
+ def prepare_start
158
+ @remote_script = cp_script_to_remote
159
+ @output_path = File.join(File.dirname(@remote_script), 'output')
160
+ @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
161
+ end
162
+
163
+ # the script that initiates the execution
164
+ def initialization_script
165
+ su_method = @user_method.instance_of?(SuUserMethod)
166
+ # 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})"
170
+ SCRIPT
171
+ end
172
+
173
+ def refresh
174
+ return if @session.nil?
175
+ super
176
+ ensure
177
+ check_expecting_disconnect
178
+ end
179
+
180
+ def kill
181
+ if @session
182
+ run_sync("pkill -f #{remote_command_file('script')}")
183
+ else
184
+ logger.debug('connection closed')
185
+ end
186
+ rescue StandardError => e
187
+ publish_exception('Unexpected error', e, false)
188
+ end
189
+
190
+ def timeout
191
+ @logger.debug('job timed out')
192
+ super
193
+ end
194
+
195
+ def timeout_interval
196
+ execution_timeout_interval
197
+ end
198
+
199
+ def close_session
200
+ @session = nil
201
+ raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
202
+ @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
+ *, err = session(args, in_stream: false, out_stream: false)
205
+ read_output_debug(err)
206
+ end
207
+
208
+ def close
209
+ run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
210
+ rescue StandardError => e
211
+ publish_exception('Error when removing remote working dir', e, false)
212
+ ensure
213
+ close_session if @session
214
+ FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
215
+ end
216
+
217
+ def publish_data(data, type)
218
+ super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
219
+ @user_method.on_data(data, @command_in)
220
+ end
221
+
222
+ private
223
+
224
+ def indent_multiline(string)
225
+ string.lines.map { |line| " | #{line}" }.join
226
+ end
227
+
228
+ def should_cleanup?
229
+ @session && @cleanup_working_dirs
230
+ end
231
+
232
+ # Creates session with three pipes - one for reading and two for
233
+ # writing. Similar to `Open3.popen3` method but without creating
234
+ # a separate thread to monitor it.
235
+ def session(args, in_stream: true, out_stream: true, err_stream: true)
236
+ @session = true
237
+
238
+ in_read, in_write = in_stream ? IO.pipe : '/dev/null'
239
+ out_read, out_write = out_stream ? IO.pipe : [nil, '/dev/null']
240
+ err_read, err_write = err_stream ? IO.pipe : [nil, '/dev/null']
241
+ command_pid = spawn(*args, :in => in_read, :out => out_write, :err => err_write)
242
+ in_read.close if in_stream
243
+ out_write.close if out_stream
244
+ err_write.close if err_stream
245
+
246
+ return command_pid, in_write, out_read, err_read
247
+ end
248
+
249
+ def ssh_options(with_pty = false)
250
+ ssh_options = []
251
+ ssh_options << "-tt" if with_pty
252
+ ssh_options << "-o User=#{@ssh_user}"
253
+ ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
254
+ ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
255
+ ssh_options << "-o IdentitiesOnly=yes"
256
+ ssh_options << "-o StrictHostKeyChecking=no"
257
+ ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
258
+ ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
259
+ ssh_options << "-o NumberOfPasswordPrompts=1"
260
+ ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
261
+ ssh_options << "-o ControlMaster=auto"
262
+ ssh_options << "-o ControlPath=#{local_command_file("socket")}"
263
+ ssh_options << "-o ControlPersist=yes"
264
+ end
265
+
266
+ def settings
267
+ Proxy::RemoteExecution::Ssh::Plugin.settings
268
+ end
269
+
270
+ def get_args(command, with_pty = false)
271
+ args = []
272
+ args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
273
+ args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
274
+ args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
275
+ end
276
+
277
+ # Initiates run of the remote command and yields the data when
278
+ # available. The yielding doesn't happen automatically, but as
279
+ # part of calling the `refresh` method.
280
+ def run_async(command)
281
+ raise 'Async command already in progress' if @started
282
+
283
+ @started = false
284
+ @user_method.reset
285
+ @command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
286
+ @started = true
287
+
288
+ return true
289
+ end
290
+
291
+ def run_started?
292
+ @started && @user_method.sent_all_data?
293
+ end
294
+
295
+ def read_output_debug(err_io, out_io = nil)
296
+ stdout = ''
297
+ debug_str = ''
298
+
299
+ if out_io
300
+ stdout += out_io.read until out_io.eof? rescue
301
+ out_io.close
302
+ end
303
+ debug_str += err_io.read until err_io.eof? rescue
304
+ err_io.close
305
+ debug_str.lines.each { |line| @logger.debug(line.strip) }
306
+
307
+ return stdout, debug_str
308
+ end
309
+
310
+ def run_sync(command, stdin = nil)
311
+ pid, tx, rx, err = session(get_args(command))
312
+ tx.puts(stdin) unless stdin.nil?
313
+ tx.close
314
+ stdout, stderr = read_output_debug(err, rx)
315
+ exit_status = Process.wait2(pid)[1].exitstatus
316
+ return exit_status, stdout, stderr
317
+ end
318
+
319
+ def prepare_known_hosts
320
+ path = local_command_file('known_hosts')
321
+ if @host_public_key
322
+ write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
323
+ end
324
+ return path
325
+ end
326
+
327
+ def local_command_dir
328
+ File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
329
+ end
330
+
331
+ def local_command_file(filename)
332
+ File.join(ensure_local_directory(local_command_dir), filename)
333
+ end
334
+
335
+ def remote_command_dir
336
+ File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
337
+ end
338
+
339
+ def remote_command_file(filename)
340
+ File.join(remote_command_dir, filename)
341
+ end
342
+
343
+ def ensure_local_directory(path)
344
+ if File.exist?(path)
345
+ raise "#{path} expected to be a directory" unless File.directory?(path)
346
+ else
347
+ FileUtils.mkdir_p(path)
348
+ end
349
+ return path
350
+ end
351
+
352
+ def cp_script_to_remote(script = @script, name = 'script')
353
+ path = remote_command_file(name)
354
+ @logger.debug("copying script to #{path}:\n#{indent_multiline(script)}")
355
+ upload_data(sanitize_script(script), path, 555)
356
+ end
357
+
358
+ def upload_data(data, path, permissions = 555)
359
+ ensure_remote_directory File.dirname(path)
360
+ # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
361
+ # This is used to write to $path with elevated permissions, solutions using cat and output redirection
362
+ # would not work, because the redirection would happen in the non-elevated shell.
363
+ command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
364
+
365
+ @logger.debug("Sending data to #{path} on remote host:\n#{data}")
366
+ status, _out, err = run_sync(command, data)
367
+
368
+ @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
369
+ if status != 0
370
+ raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
371
+ end
372
+
373
+ path
374
+ end
375
+
376
+ def upload_file(local_path, remote_path)
377
+ mode = File.stat(local_path).mode.to_s(8)[-3..-1]
378
+ @logger.debug("Uploading local file: #{local_path} as #{remote_path} with #{mode} permissions")
379
+ upload_data(File.read(local_path), remote_path, mode)
380
+ end
381
+
382
+ def ensure_remote_directory(path)
383
+ exit_code, _output, err = run_sync("mkdir -p #{path}")
384
+ if exit_code != 0
385
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
386
+ end
387
+ end
388
+
389
+ def sanitize_script(script)
390
+ script.tr("\r", '')
391
+ end
392
+
393
+ def write_command_file_locally(filename, content)
394
+ path = local_command_file(filename)
395
+ ensure_local_directory(File.dirname(path))
396
+ File.write(path, content)
397
+ return path
398
+ end
399
+
400
+ # when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot)
401
+ # or it's an error. When it's expected, we expect the script to produce 'restart host' as
402
+ # its last command output
403
+ def check_expecting_disconnect
404
+ last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
405
+ return unless last_output
406
+
407
+ if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
408
+ @expecting_disconnect = true
409
+ end
410
+ end
411
+
412
+ def available_authentication_methods
413
+ methods = %w[publickey] # Always use pubkey auth as fallback
414
+ methods << 'gssapi-with-mic' if settings[:kerberos_auth]
415
+ methods.unshift('password') if @ssh_password
416
+ methods
417
+ end
418
+ end
419
+ # rubocop:enable Metrics/ClassLength
420
+ end
@@ -0,0 +1,7 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ module Runners
3
+ require 'smart_proxy_remote_execution_ssh/runners/script_runner'
4
+ require 'smart_proxy_remote_execution_ssh/runners/polling_script_runner'
5
+ require 'smart_proxy_remote_execution_ssh/runners/fake_script_runner'
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ require 'open3'
2
+
3
+ module Proxy::RemoteExecution
4
+ module Utils
5
+ class << self
6
+ def prune_known_hosts!(hostname, port, logger = Logger.new($stdout))
7
+ return if Net::SSH::KnownHosts.search_for(hostname).empty?
8
+
9
+ target = if port == 22
10
+ hostname
11
+ else
12
+ "[#{hostname}]:#{port}"
13
+ end
14
+
15
+ Open3.popen3('ssh-keygen', '-R', target) do |_stdin, stdout, _stderr, wait_thr|
16
+ wait_thr.join
17
+ stdout.read
18
+ end
19
+ rescue Errno::ENOENT => e
20
+ logger.warn("Could not remove #{hostname} from know_hosts: #{e}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.3.2'
4
+ VERSION = '0.5.1'
5
5
  end
6
6
  end
7
7
  end