smart_proxy_remote_execution_ssh 0.5.3 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
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