smart_proxy_remote_execution_ssh 0.5.3 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abf6517f6a98386e246650454c7deb049b7583c7653040eb63bbdf8e3cc27f0a
4
- data.tar.gz: 12b0aa0d067e45c73ee65286f69d1556f7ae8b1c47624845e1e0a7e96c341e95
3
+ metadata.gz: 65bdd1bbf94900594e4a6acf2d3c1522e778dc7ca5eff5c70b085a5cf9b077c9
4
+ data.tar.gz: 7c1321bd2b69b5e4498df66b000814cffaffcc770220e09648c98534e60ce900
5
5
  SHA512:
6
- metadata.gz: f873d27dc9b5ba0b9c10d7d227058618d901a48643ba31e491e1530b34356aa88ac8a197e9731d83a2a535ffa7577ba674578af8b90de3d02f7537d6359063d5
7
- data.tar.gz: d72dfc490d0f71a076885eeabf435a89bca3658ce31a465666725a19e1d0f3c682d51f899922a2b6dbfdbfe8ee8d880fc0b0d0a13ac2536c5ce8f276f64b059b
6
+ metadata.gz: 439a6ac3a27140f930a52806e6708d1961343de024b0b5398be70add77be7a0a095a609c03c282371e9c0f8f6dead04b906b9fcb64618463594e69e5ab2c0290
7
+ data.tar.gz: 54cfbbbc9001fef48bf7ea607ad68b507fcd5610213ec81e9176d705364b0c5f207fbaed441102a645d25f86ff3d2a653fdce69a019daf83f84e45fa6e098315
@@ -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|
@@ -180,10 +180,11 @@ module Proxy::RemoteExecution
180
180
 
181
181
  def inner_system_ssh_loop(out_buf, err_buf, in_buf, pid)
182
182
  err_buf_raw = ''
183
- readers = [buf_socket, out_buf, err_buf]
184
183
  loop do
184
+ readers = [buf_socket, out_buf, err_buf].reject { |io| io.closed? }
185
+ writers = [buf_socket, in_buf].select { |io| io.pending_writes? }
185
186
  # Prime the sockets for reading
186
- ready_readers, ready_writers = IO.select(readers, [buf_socket, in_buf], nil, 300)
187
+ ready_readers, ready_writers = IO.select(readers, writers)
187
188
  (ready_readers || []).each { |reader| reader.close if reader.fill.zero? }
188
189
 
189
190
  proxy_data(out_buf, in_buf)
@@ -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
 
@@ -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,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
55
53
  begin
56
- _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
54
+ pm = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
57
55
  rescue StandardError => e
58
56
  @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
59
57
  end
60
58
 
61
- process_retrieved_data(output, err)
59
+ process_retrieved_data(pm.stdout.to_s.chomp, pm.stderr.to_s.chomp)
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,7 +131,11 @@ 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
@@ -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,63 +275,58 @@ 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"
282
+ ssh_options << "-o ProxyCommand=none"
266
283
  end
267
284
 
268
285
  def settings
269
286
  Proxy::RemoteExecution::Ssh::Plugin.settings
270
287
  end
271
288
 
272
- def get_args(command, with_pty = false)
289
+ def get_args(command, with_pty = false, quiet: false)
273
290
  args = []
274
291
  args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
275
292
  args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
276
- args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
293
+ args += ['/usr/bin/ssh', @host, ssh_options(with_pty, quiet: quiet), command].flatten
277
294
  end
278
295
 
279
296
  # Initiates run of the remote command and yields the data when
280
297
  # available. The yielding doesn't happen automatically, but as
281
298
  # part of calling the `refresh` method.
282
299
  def run_async(command)
283
- raise 'Async command already in progress' if @started
300
+ raise 'Async command already in progress' if @process_manager&.started?
284
301
 
285
- @started = false
286
302
  @user_method.reset
287
- @command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
288
- @started = true
303
+ initialize_command(*get_args(command, true, quiet: true))
289
304
 
290
- return true
305
+ true
291
306
  end
292
307
 
293
308
  def run_started?
294
- @started && @user_method.sent_all_data?
309
+ @process_manager&.started? && @user_method.sent_all_data?
295
310
  end
296
311
 
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
312
+ def run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil)
313
+ pm = Proxy::Dynflow::ProcessManager.new(get_args(command, tty))
314
+ callback = proc do |data|
315
+ data.each_line do |line|
316
+ logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
317
+ user_method.on_data(data, pm.stdin) if user_method
318
+ end
319
+ ''
304
320
  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
321
+ pm.on_stdout(&callback)
322
+ pm.on_stderr(&callback)
323
+ pm.start!
324
+ unless pm.status
325
+ pm.stdin.io.puts(stdin) if stdin
326
+ pm.stdin.io.close if close_stdin
327
+ pm.run!
328
+ end
329
+ pm
319
330
  end
320
331
 
321
332
  def prepare_known_hosts
@@ -334,6 +345,10 @@ module Proxy::RemoteExecution::Ssh::Runners
334
345
  File.join(ensure_local_directory(local_command_dir), filename)
335
346
  end
336
347
 
348
+ def socket_file
349
+ File.join(ensure_local_directory(@socket_working_dir), @id)
350
+ end
351
+
337
352
  def remote_command_dir
338
353
  File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
339
354
  end
@@ -362,15 +377,13 @@ module Proxy::RemoteExecution::Ssh::Runners
362
377
  # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
363
378
  # This is used to write to $path with elevated permissions, solutions using cat and output redirection
364
379
  # would not work, because the redirection would happen in the non-elevated shell.
365
- command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
380
+ command = "tee #{path} >/dev/null && chmod #{permissions} #{path}"
366
381
 
367
382
  @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
383
+ ensure_remote_command(command,
384
+ stdin: data,
385
+ error: "Unable to upload file to #{path} on remote system, exit code: %{exit_code}"
386
+ )
374
387
 
375
388
  path
376
389
  end
@@ -382,9 +395,15 @@ module Proxy::RemoteExecution::Ssh::Runners
382
395
  end
383
396
 
384
397
  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}"
398
+ ensure_remote_command("mkdir -p #{path}",
399
+ error: "Unable to create directory #{path} on remote system, exit code: %{exit_code}"
400
+ )
401
+ end
402
+
403
+ def ensure_remote_command(cmd, error: nil, **kwargs)
404
+ if (pm = run_sync(cmd, **kwargs)).status != 0
405
+ msg = error || 'Failed to run command %{command} on remote machine, exit code: %{exit_code}'
406
+ raise(msg % { command: cmd, exit_code: pm.status })
388
407
  end
389
408
  end
390
409
 
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.5.3'
4
+ VERSION = '0.7.1'
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.7.1
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
@@ -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.4
206
+ rubygems_version: 3.2.26
207
207
  signing_key:
208
208
  specification_version: 4
209
209
  summary: Ssh remote execution provider for Foreman Smart-Proxy