smart_proxy_remote_execution_ssh 0.5.2 → 0.7.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: 1ba2217281e0dfb1199d345fd81e2e135c3b3f83e590177ecc8826dfb8176262
4
- data.tar.gz: db341f9a3cbed3045938d16cc0b772887098ee1db4adc2d4b515449785afbf39
3
+ metadata.gz: e58a285a16b7d9a173ee4c29639b3367517c5d84f7979bdc84ab2c502a51edb1
4
+ data.tar.gz: 57892ef63dc0a6bd156ae6fef562b8a4096a7a3cd6a2a03828b4598b1b1d37c7
5
5
  SHA512:
6
- metadata.gz: 4564886d08716efa9f7d40b887027d67dcd2bb9b02688033cd6f9f7f5a633a2e215ff5e567e144db687f7bb51f624b19aa240159c0f2a15ccaccbe983161c28a
7
- data.tar.gz: fb3e8fd7bdba9193731c1399f2809e5625c6313b9cf1a1d1a6daad1e2bfc012e7057ebc7a3766e9d4bf9e1938b898dabfeea042f87db2d31b20dd07964a6744e
6
+ metadata.gz: 89cc41ef35aa11210f322afc3b7ae35aa269cc4ed8fcef8faff11e32ac37fe765cc083d0bdef2b9c58a54f96be5c8224c5916b85cbb393040f6e715816bf51b7
7
+ data.tar.gz: ec608d3b666443e6afbb97d28f1014f459d746d65adcf0e1f0cd43d168961e05884f8d3e393814f594527a294d6ac56d2af432509b8ecb99cfcf0df7101f501b
@@ -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|
@@ -2,6 +2,10 @@ module Proxy::RemoteExecution::Ssh
2
2
  class Plugin < Proxy::Plugin
3
3
  SSH_LOG_LEVELS = %w[debug info error fatal].freeze
4
4
  MODES = %i[ssh async-ssh 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,12 +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
44
  | #{@user_method.cli_command_prefix} sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
44
- | echo $! > '#{@base_dir}/pid'
45
45
  SCRIPT
46
46
  end
47
47
 
@@ -50,18 +50,23 @@ module Proxy::RemoteExecution::Ssh::Runners
50
50
  end
51
51
 
52
52
  def refresh
53
- err = output = nil
54
53
  begin
55
- _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
54
+ pm = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
56
55
  rescue StandardError => e
57
56
  @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
58
57
  end
59
58
 
60
- process_retrieved_data(output, err)
59
+ process_retrieved_data(pm.stdout.to_s.chomp, pm.stderr.to_s.chomp)
61
60
  ensure
62
61
  destroy_session
63
62
  end
64
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
+
65
70
  def process_retrieved_data(output, err)
66
71
  return if output.nil? || output.empty?
67
72
 
@@ -126,7 +131,11 @@ module Proxy::RemoteExecution::Ssh::Runners
126
131
  end
127
132
 
128
133
  def cleanup
129
- 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
130
139
  end
131
140
 
132
141
  def destroy_session
@@ -1,5 +1,6 @@
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'
3
4
 
4
5
  module Proxy::RemoteExecution::Ssh::Runners
5
6
  class EffectiveUserMethod
@@ -12,9 +13,9 @@ module Proxy::RemoteExecution::Ssh::Runners
12
13
  @password_sent = false
13
14
  end
14
15
 
15
- def on_data(received_data, ssh_channel)
16
+ def on_data(received_data, io_buffer)
16
17
  if received_data.match(login_prompt)
17
- ssh_channel.puts(effective_user_password)
18
+ io_buffer.add_data(effective_user_password + "\n")
18
19
  @password_sent = true
19
20
  end
20
21
  end
@@ -90,7 +91,7 @@ module Proxy::RemoteExecution::Ssh::Runners
90
91
 
91
92
  # rubocop:disable Metrics/ClassLength
92
93
  class ScriptRunner < Proxy::Dynflow::Runner::Base
93
- include Proxy::Dynflow::Runner::Command
94
+ include Proxy::Dynflow::Runner::ProcessManagerCommand
94
95
  attr_reader :execution_timeout_interval
95
96
 
96
97
  EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
@@ -110,7 +111,8 @@ module Proxy::RemoteExecution::Ssh::Runners
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
@@ -141,6 +143,8 @@ module Proxy::RemoteExecution::Ssh::Runners
141
143
 
142
144
  def start
143
145
  Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
146
+ establish_connection
147
+ preflight_checks
144
148
  prepare_start
145
149
  script = initialization_script
146
150
  logger.debug("executing script:\n#{indent_multiline(script)}")
@@ -154,32 +158,61 @@ module Proxy::RemoteExecution::Ssh::Runners
154
158
  run_async(*args)
155
159
  end
156
160
 
161
+ def preflight_checks
162
+ ensure_remote_command(cp_script_to_remote("#!/bin/sh\nexec true", 'test'),
163
+ error: 'Failed to execute script on remote machine, exit code: %{exit_code}.'
164
+ )
165
+ unless @user_method.is_a? NoopUserMethod
166
+ path = cp_script_to_remote("#!/bin/sh\nexec #{@user_method.cli_command_prefix} true", 'effective-user-test')
167
+ ensure_remote_command(path,
168
+ error: 'Failed to change to effective user, exit code: %{exit_code}',
169
+ tty: true,
170
+ user_method: @user_method,
171
+ close_stdin: false)
172
+ end
173
+ end
174
+
175
+ def establish_connection
176
+ # run_sync ['-f', '-N'] would be cleaner, but ssh does not close its
177
+ # stderr which trips up the process manager which expects all FDs to be
178
+ # closed
179
+ ensure_remote_command(
180
+ 'true',
181
+ error: 'Failed to establish connection to remote host, exit code: %{exit_code}'
182
+ )
183
+ end
184
+
157
185
  def prepare_start
158
186
  @remote_script = cp_script_to_remote
159
187
  @output_path = File.join(File.dirname(@remote_script), 'output')
160
188
  @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
189
+ @pid_path = File.join(File.dirname(@remote_script), 'pid')
190
+ @remote_script_wrapper = upload_data("echo $$ > #{@pid_path}; exec \"$@\";", File.join(File.dirname(@remote_script), 'script-wrapper'), 555)
161
191
  end
162
192
 
163
193
  # the script that initiates the execution
164
194
  def initialization_script
165
195
  su_method = @user_method.instance_of?(SuUserMethod)
166
196
  # 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})"
197
+ <<~SCRIPT
198
+ sh <<EOF | /usr/bin/tee #{@output_path}
199
+ #{@remote_script_wrapper} #{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}
200
+ echo \\$?>#{@exit_code_path}
201
+ EOF
202
+ exit $(cat #{@exit_code_path})
170
203
  SCRIPT
171
204
  end
172
205
 
173
206
  def refresh
174
- return if @session.nil?
207
+ return if @process_manager.nil?
175
208
  super
176
209
  ensure
177
210
  check_expecting_disconnect
178
211
  end
179
212
 
180
213
  def kill
181
- if @session
182
- run_sync("pkill -f #{remote_command_file('script')}")
214
+ if @process_manager&.started?
215
+ run_sync("pkill -P $(cat #{@pid_path})")
183
216
  else
184
217
  logger.debug('connection closed')
185
218
  end
@@ -197,28 +230,28 @@ module Proxy::RemoteExecution::Ssh::Runners
197
230
  end
198
231
 
199
232
  def close_session
200
- @session = nil
201
- raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
233
+ raise 'Control socket file does not exist' unless File.exist?(socket_file)
202
234
  @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
235
+ args = ['/usr/bin/ssh', @host, "-o", "ControlPath=#{socket_file}", "-O", "exit"].flatten
236
+ pm = Proxy::Dynflow::ProcessManager.new(args)
237
+ pm.on_stdout { |data| @logger.debug "[close_session]: #{data.chomp}"; data }
238
+ pm.on_stderr { |data| @logger.debug "[close_session]: #{data.chomp}"; data }
239
+ pm.run!
208
240
  end
209
241
 
210
242
  def close
211
- run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
243
+ run_sync("rm -rf #{remote_command_dir}") if should_cleanup?
212
244
  rescue StandardError => e
213
245
  publish_exception('Error when removing remote working dir', e, false)
214
246
  ensure
215
- close_session if @session
247
+ close_session if @process_manager
216
248
  FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
217
249
  end
218
250
 
219
- def publish_data(data, type)
251
+ def publish_data(data, type, pm = nil)
252
+ pm ||= @process_manager
220
253
  super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
221
- @user_method.on_data(data, @command_in)
254
+ @user_method.on_data(data, pm.stdin) if pm
222
255
  end
223
256
 
224
257
  private
@@ -228,27 +261,10 @@ module Proxy::RemoteExecution::Ssh::Runners
228
261
  end
229
262
 
230
263
  def should_cleanup?
231
- @session && @cleanup_working_dirs
264
+ @process_manager && @cleanup_working_dirs
232
265
  end
233
266
 
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)
267
+ def ssh_options(with_pty = false, quiet: false)
252
268
  ssh_options = []
253
269
  ssh_options << "-tt" if with_pty
254
270
  ssh_options << "-o User=#{@ssh_user}"
@@ -259,9 +275,9 @@ module Proxy::RemoteExecution::Ssh::Runners
259
275
  ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
260
276
  ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
261
277
  ssh_options << "-o NumberOfPasswordPrompts=1"
262
- ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
278
+ ssh_options << "-o LogLevel=#{quiet ? 'quiet' : settings[:ssh_log_level]}"
263
279
  ssh_options << "-o ControlMaster=auto"
264
- ssh_options << "-o ControlPath=#{local_command_file("socket")}"
280
+ ssh_options << "-o ControlPath=#{socket_file}"
265
281
  ssh_options << "-o ControlPersist=yes"
266
282
  end
267
283
 
@@ -269,53 +285,47 @@ module Proxy::RemoteExecution::Ssh::Runners
269
285
  Proxy::RemoteExecution::Ssh::Plugin.settings
270
286
  end
271
287
 
272
- def get_args(command, with_pty = false)
288
+ def get_args(command, with_pty = false, quiet: false)
273
289
  args = []
274
290
  args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
275
291
  args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
276
- args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
292
+ args += ['/usr/bin/ssh', @host, ssh_options(with_pty, quiet: quiet), command].flatten
277
293
  end
278
294
 
279
295
  # Initiates run of the remote command and yields the data when
280
296
  # available. The yielding doesn't happen automatically, but as
281
297
  # part of calling the `refresh` method.
282
298
  def run_async(command)
283
- raise 'Async command already in progress' if @started
299
+ raise 'Async command already in progress' if @process_manager&.started?
284
300
 
285
- @started = false
286
301
  @user_method.reset
287
- @command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
288
- @started = true
302
+ initialize_command(*get_args(command, true, quiet: true))
289
303
 
290
- return true
304
+ true
291
305
  end
292
306
 
293
307
  def run_started?
294
- @started && @user_method.sent_all_data?
308
+ @process_manager&.started? && @user_method.sent_all_data?
295
309
  end
296
310
 
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
311
+ def run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil)
312
+ pm = Proxy::Dynflow::ProcessManager.new(get_args(command, tty))
313
+ callback = proc do |data|
314
+ data.each_line do |line|
315
+ logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
316
+ user_method.on_data(data, pm.stdin) if user_method
317
+ end
318
+ ''
304
319
  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
310
- end
311
-
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
320
+ pm.on_stdout(&callback)
321
+ pm.on_stderr(&callback)
322
+ pm.start!
323
+ unless pm.status
324
+ pm.stdin.io.puts(stdin) if stdin
325
+ pm.stdin.io.close if close_stdin
326
+ pm.run!
327
+ end
328
+ pm
319
329
  end
320
330
 
321
331
  def prepare_known_hosts
@@ -334,6 +344,10 @@ module Proxy::RemoteExecution::Ssh::Runners
334
344
  File.join(ensure_local_directory(local_command_dir), filename)
335
345
  end
336
346
 
347
+ def socket_file
348
+ File.join(ensure_local_directory(@socket_working_dir), @id)
349
+ end
350
+
337
351
  def remote_command_dir
338
352
  File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
339
353
  end
@@ -362,15 +376,13 @@ module Proxy::RemoteExecution::Ssh::Runners
362
376
  # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
363
377
  # This is used to write to $path with elevated permissions, solutions using cat and output redirection
364
378
  # would not work, because the redirection would happen in the non-elevated shell.
365
- command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
379
+ command = "tee #{path} >/dev/null && chmod #{permissions} #{path}"
366
380
 
367
381
  @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
382
+ ensure_remote_command(command,
383
+ stdin: data,
384
+ error: "Unable to upload file to #{path} on remote system, exit code: %{exit_code}"
385
+ )
374
386
 
375
387
  path
376
388
  end
@@ -382,9 +394,15 @@ module Proxy::RemoteExecution::Ssh::Runners
382
394
  end
383
395
 
384
396
  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}"
397
+ ensure_remote_command("mkdir -p #{path}",
398
+ error: "Unable to create directory #{path} on remote system, exit code: %{exit_code}"
399
+ )
400
+ end
401
+
402
+ def ensure_remote_command(cmd, error: nil, **kwargs)
403
+ if (pm = run_sync(cmd, **kwargs)).status != 0
404
+ msg = error || 'Failed to run command %{command} on remote machine, exit code: %{exit_code}'
405
+ raise(msg % { command: cmd, exit_code: pm.status })
388
406
  end
389
407
  end
390
408
 
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.5.2'
4
+ VERSION = '0.7.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.2
4
+ version: 0.7.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-02-07 00:00:00.000000000 Z
11
+ date: 2022-05-13 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
@@ -203,7 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
203
203
  - !ruby/object:Gem::Version
204
204
  version: '0'
205
205
  requirements: []
206
- rubygems_version: 3.1.2
206
+ rubygems_version: 3.1.4
207
207
  signing_key:
208
208
  specification_version: 4
209
209
  summary: Ssh remote execution provider for Foreman Smart-Proxy