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.
- checksums.yaml +4 -4
- data/{bundler.plugins.d → bundler.d}/remote_execution_ssh.rb +0 -0
- data/lib/smart_proxy_remote_execution_ssh/actions/pull_script.rb +110 -0
- data/lib/smart_proxy_remote_execution_ssh/actions/run_script.rb +34 -0
- data/lib/smart_proxy_remote_execution_ssh/actions.rb +6 -0
- data/lib/smart_proxy_remote_execution_ssh/api.rb +49 -0
- data/lib/smart_proxy_remote_execution_ssh/async_scripts/control.sh +110 -0
- data/lib/smart_proxy_remote_execution_ssh/async_scripts/retrieve.sh +151 -0
- data/lib/smart_proxy_remote_execution_ssh/cockpit.rb +87 -71
- data/lib/smart_proxy_remote_execution_ssh/dispatcher.rb +10 -0
- data/lib/smart_proxy_remote_execution_ssh/job_storage.rb +51 -0
- data/lib/smart_proxy_remote_execution_ssh/log_filter.rb +14 -0
- data/lib/smart_proxy_remote_execution_ssh/net_ssh_compat.rb +228 -0
- data/lib/smart_proxy_remote_execution_ssh/plugin.rb +32 -10
- data/lib/smart_proxy_remote_execution_ssh/runners/fake_script_runner.rb +87 -0
- data/lib/smart_proxy_remote_execution_ssh/runners/polling_script_runner.rb +139 -0
- data/lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb +420 -0
- data/lib/smart_proxy_remote_execution_ssh/runners.rb +7 -0
- data/lib/smart_proxy_remote_execution_ssh/utils.rb +24 -0
- data/lib/smart_proxy_remote_execution_ssh/version.rb +1 -1
- data/lib/smart_proxy_remote_execution_ssh.rb +60 -2
- data/settings.d/remote_execution_ssh.yml.example +12 -3
- metadata +33 -5
@@ -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
|