smart_proxy_remote_execution_ssh 0.7.3 → 0.8.0

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: 7ca390cdbb692725653079d4c547ee4f3e1603111ba4f591ca29dc0ea5040fe9
4
- data.tar.gz: e47e47c97c5ee39a50349abcb2128d964025565f08fdf4edc872661111e14b2e
3
+ metadata.gz: 1a599d3732d4f6b064a850ae8baced28354980096785a340828533f953f844c4
4
+ data.tar.gz: 160b80ab983f0eb7b987987191b48c19db42d3b76079b0ba6639ec831dccc6f3
5
5
  SHA512:
6
- metadata.gz: c72ce769673fd6c0055e4d36d6cf049ed16c97e9ce7f34a6f3c2169681aea0c54c5079fcabb4493ec1f8e2d4fa477c5c253372a61bb53da4211406156535cd24
7
- data.tar.gz: a8c18703b55a9562707f7f9c7bb719d44c0d816c1d4f8b40beaefcfd7588251bddab5ab5345145ccd2e80300786bf066c53a6e2af72e4918019c011f73db3db9
6
+ metadata.gz: 349961aa474809225d9781bda09687eb23f00db51cca9f758a57e075fa3d19fc07b729569b064887f91bb7f07190152d6ec9cb01e8ccf48df0841e9c3f85d10e
7
+ data.tar.gz: 8ad40859c92f7d1cd089adf0f37bb0aaa8add745cd461c2e1ed5b484d3ae6b856ac0ee53e61c62e89d564a4ba676b22bf5f01618da970986cd60e42591ff6346
@@ -1,5 +1,6 @@
1
1
  require 'smart_proxy_remote_execution_ssh/net_ssh_compat'
2
2
  require 'forwardable'
3
+ require 'securerandom'
3
4
 
4
5
  module Proxy::RemoteExecution
5
6
  module Cockpit
@@ -164,9 +165,8 @@ module Proxy::RemoteExecution
164
165
  out_read, out_write = IO.pipe
165
166
  err_read, err_write = IO.pipe
166
167
 
167
- # Force the script runner to initialize its logger
168
- script_runner.logger
169
- pid = spawn(*script_runner.send(:get_args, command), :in => in_read, :out => out_write, :err => err_write)
168
+ connection.establish!
169
+ pid = spawn(*connection.command(command), :in => in_read, :out => out_write, :err => err_write)
170
170
  [in_read, out_write, err_write].each(&:close)
171
171
 
172
172
  send_start
@@ -176,6 +176,8 @@ module Proxy::RemoteExecution
176
176
  in_buf = MiniSSLBufferedSocket.new(in_write)
177
177
 
178
178
  inner_system_ssh_loop out_buf, err_buf, in_buf, pid
179
+ ensure
180
+ connection.disconnect!
179
181
  end
180
182
 
181
183
  def inner_system_ssh_loop(out_buf, err_buf, in_buf, pid)
@@ -189,7 +191,7 @@ module Proxy::RemoteExecution
189
191
 
190
192
  proxy_data(out_buf, in_buf)
191
193
  if buf_socket.closed?
192
- script_runner.close_session
194
+ connection.disconnect!
193
195
  end
194
196
 
195
197
  if out_buf.closed?
@@ -263,10 +265,10 @@ module Proxy::RemoteExecution
263
265
  params["hostname"]
264
266
  end
265
267
 
266
- def script_runner
267
- @script_runner ||= Proxy::RemoteExecution::Ssh::Runners::ScriptRunner.build(
268
+ def connection
269
+ @connection ||= Proxy::RemoteExecution::Ssh::Runners::MultiplexedSSHConnection.new(
268
270
  runner_params,
269
- suspended_action: nil
271
+ logger: logger
270
272
  )
271
273
  end
272
274
 
@@ -279,6 +281,7 @@ module Proxy::RemoteExecution
279
281
  # For compatibility only
280
282
  ret[:script] = nil
281
283
  ret[:hostname] = host
284
+ ret[:id] = SecureRandom.uuid
282
285
  ret
283
286
  end
284
287
  end
@@ -0,0 +1,23 @@
1
+ # lib/command_logging.rb
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ module CommandLogging
5
+ def log_command(command, label: "Running")
6
+ command = command.join(' ')
7
+ label = "#{label}: " if label
8
+ logger.debug("#{label}#{command}")
9
+ end
10
+
11
+ def set_pm_debug_logging(pm, capture: false, user_method: nil)
12
+ callback = proc do |data|
13
+ data.each_line do |line|
14
+ logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
15
+ user_method.on_data(data, pm.stdin) if user_method
16
+ end
17
+ ''
18
+ end
19
+ pm.on_stdout(&callback)
20
+ pm.on_stderr(&callback)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,196 @@
1
+ require 'smart_proxy_remote_execution_ssh/command_logging'
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ class SensitiveString
5
+ def initialize(value, mask: '*****')
6
+ @value = value
7
+ @mask = mask
8
+ end
9
+
10
+ def inspect
11
+ '"' + to_s + '"'
12
+ end
13
+
14
+ def to_s
15
+ @mask
16
+ end
17
+
18
+ def to_str
19
+ @value
20
+ end
21
+ end
22
+
23
+ class AuthenticationMethod
24
+ attr_reader :name
25
+ def initialize(name, prompt: nil, password: nil)
26
+ @name = name
27
+ @prompt = prompt
28
+ @password = password
29
+ end
30
+
31
+ def ssh_command_prefix
32
+ return [] unless @password
33
+
34
+ prompt = ['-P', @prompt] if @prompt
35
+ [{'SSHPASS' => SensitiveString.new(@password)}, '/usr/bin/sshpass', '-e', prompt].compact
36
+ end
37
+
38
+ def ssh_options
39
+ ["-o PreferredAuthentications=#{name}",
40
+ "-o NumberOfPasswordPrompts=#{@password ? 1 : 0}"]
41
+ end
42
+ end
43
+
44
+ class MultiplexedSSHConnection
45
+ include CommandLogging
46
+
47
+ attr_reader :logger
48
+ def initialize(options, logger:)
49
+ @logger = logger
50
+
51
+ @id = options.fetch(:id)
52
+ @host = options.fetch(:hostname)
53
+ @script = options.fetch(:script)
54
+ @ssh_user = options.fetch(:ssh_user, 'root')
55
+ @ssh_port = options.fetch(:ssh_port, 22)
56
+ @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
57
+ @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
58
+ @host_public_key = options.fetch(:host_public_key, nil)
59
+ @verify_host = options.fetch(:verify_host, nil)
60
+ @client_private_key_file = settings.ssh_identity_key_file
61
+
62
+ @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
63
+ @socket_working_dir = options.fetch(:socket_working_dir, settings.socket_working_dir)
64
+ @socket = nil
65
+ end
66
+
67
+ def establish!
68
+ @available_auth_methods ||= available_authentication_methods
69
+ method = @available_auth_methods.find do |method|
70
+ if try_auth_method(method)
71
+ @available_auth_methods.unshift(method).uniq!
72
+ true
73
+ end
74
+ end
75
+ method || raise("Could not establish connection to remote host using any available authentication method, tried #{@available_auth_methods.map(&:name).join(', ')}")
76
+ end
77
+
78
+ def disconnect!
79
+ return unless connected?
80
+
81
+ cmd = command(%w[-O exit])
82
+ log_command(cmd, label: "Closing shared connection")
83
+ pm = Proxy::Dynflow::ProcessManager.new(cmd)
84
+ set_pm_debug_logging(pm)
85
+ pm.run!
86
+ @socket = nil
87
+ end
88
+
89
+ def connected?
90
+ !@socket.nil?
91
+ end
92
+
93
+ def command(cmd)
94
+ raise "Cannot build command to run over multiplexed connection without having an established connection" unless connected?
95
+
96
+ ['/usr/bin/ssh', reuse_ssh_options, cmd].flatten
97
+ end
98
+
99
+ private
100
+
101
+ def try_auth_method(method)
102
+ # running "ssh -f -N" instead of "ssh true" would be cleaner, but ssh
103
+ # does not close its stderr which trips up the process manager which
104
+ # expects all FDs to be closed
105
+
106
+ full_command = [method.ssh_command_prefix, '/usr/bin/ssh', establish_ssh_options, method.ssh_options, @host, 'true'].flatten
107
+ log_command(full_command)
108
+ pm = Proxy::Dynflow::ProcessManager.new(full_command)
109
+ pm.start!
110
+ if pm.status
111
+ raise pm.stderr.to_s
112
+ else
113
+ set_pm_debug_logging(pm)
114
+ pm.stdin.io.close
115
+ pm.run!
116
+ end
117
+
118
+ if pm.status.zero?
119
+ logger.debug("Established connection using authentication method #{method.name}")
120
+ @socket = socket_file
121
+ true
122
+ else
123
+ logger.debug("Failed to establish connection using authentication method #{method.name}")
124
+ false
125
+ end
126
+ end
127
+
128
+ def settings
129
+ Proxy::RemoteExecution::Ssh::Plugin.settings
130
+ end
131
+
132
+ def available_authentication_methods
133
+ methods = []
134
+ methods << AuthenticationMethod.new('password', password: @ssh_password) if @ssh_password
135
+ if verify_key_passphrase
136
+ methods << AuthenticationMethod.new('publickey', password: @key_passphrase, prompt: 'passphrase')
137
+ end
138
+ methods << AuthenticationMethod.new('gssapi-with-mic') if settings[:kerberos_auth]
139
+ raise "There are no available authentication methods" if methods.empty?
140
+ methods
141
+ end
142
+
143
+ def establish_ssh_options
144
+ return @establish_ssh_options if @establish_ssh_options
145
+ ssh_options = []
146
+ ssh_options << "-o User=#{@ssh_user}"
147
+ ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
148
+ ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
149
+ ssh_options << "-o IdentitiesOnly=yes"
150
+ ssh_options << "-o StrictHostKeyChecking=no"
151
+ ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
152
+ ssh_options << "-o LogLevel=#{ssh_log_level(true)}"
153
+ ssh_options << "-o ControlMaster=auto"
154
+ ssh_options << "-o ControlPath=#{socket_file}"
155
+ ssh_options << "-o ControlPersist=yes"
156
+ ssh_options << "-o ProxyCommand=none"
157
+ @establish_ssh_options = ssh_options
158
+ end
159
+
160
+ def reuse_ssh_options
161
+ ["-o", "ControlPath=#{@socket}", "-o", "LogLevel=#{ssh_log_level(false)}", @host]
162
+ end
163
+
164
+ def socket_file
165
+ File.join(@socket_working_dir, @id)
166
+ end
167
+
168
+ def verify_key_passphrase
169
+ command = ['/usr/bin/ssh-keygen', '-y', '-f', File.expand_path(@client_private_key_file)]
170
+ log_command(command, label: "Checking if private key has passphrase")
171
+ pm = Proxy::Dynflow::ProcessManager.new(command)
172
+ pm.start!
173
+
174
+ raise pm.stderr.to_s if pm.status
175
+
176
+ pm.stdin.io.close
177
+ pm.run!
178
+
179
+ if pm.status.zero?
180
+ logger.debug("Private key is not protected with a passphrase")
181
+ @key_passphrase = nil
182
+ else
183
+ logger.debug("Private key is protected with a passphrase")
184
+ end
185
+
186
+ return true if pm.status.zero? || @key_passphrase
187
+
188
+ logger.debug("Private key is protected with a passphrase, but no passphrase was provided")
189
+ false
190
+ end
191
+
192
+ def ssh_log_level(new_connection)
193
+ new_connection ? settings[:ssh_log_level] : 'quiet'
194
+ end
195
+ end
196
+ end
@@ -50,13 +50,13 @@ module Proxy::RemoteExecution::Ssh::Runners
50
50
  end
51
51
 
52
52
  def refresh
53
+ @connection.establish! unless @connection.connected?
53
54
  begin
54
55
  pm = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
56
+ process_retrieved_data(pm.stdout.to_s.chomp, pm.stderr.to_s.chomp)
55
57
  rescue StandardError => e
56
58
  @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
57
59
  end
58
-
59
- process_retrieved_data(pm.stdout.to_s.chomp, pm.stderr.to_s.chomp)
60
60
  ensure
61
61
  destroy_session
62
62
  end
@@ -133,12 +133,13 @@ module Proxy::RemoteExecution::Ssh::Runners
133
133
  def cleanup
134
134
  if @cleanup_working_dirs
135
135
  ensure_remote_command("rm -rf #{remote_command_dir}",
136
+ publish: true,
136
137
  error: "Unable to remove working directory #{remote_command_dir} on remote system, exit code: %{exit_code}")
137
138
  end
138
139
  end
139
140
 
140
141
  def destroy_session
141
- if @session
142
+ if @connection.connected?
142
143
  @logger.debug("Closing session with #{@ssh_user}@#{@host}")
143
144
  close_session
144
145
  end
@@ -1,6 +1,7 @@
1
1
  require 'fileutils'
2
2
  require 'smart_proxy_dynflow/runner/process_manager_command'
3
3
  require 'smart_proxy_dynflow/process_manager'
4
+ require 'smart_proxy_remote_execution_ssh/multiplexed_ssh_connection'
4
5
 
5
6
  module Proxy::RemoteExecution::Ssh::Runners
6
7
  class EffectiveUserMethod
@@ -92,6 +93,8 @@ module Proxy::RemoteExecution::Ssh::Runners
92
93
  # rubocop:disable Metrics/ClassLength
93
94
  class ScriptRunner < Proxy::Dynflow::Runner::Base
94
95
  include Proxy::Dynflow::Runner::ProcessManagerCommand
96
+ include CommandLogging
97
+
95
98
  attr_reader :execution_timeout_interval
96
99
 
97
100
  EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
@@ -103,10 +106,7 @@ module Proxy::RemoteExecution::Ssh::Runners
103
106
  @script = options.fetch(:script)
104
107
  @ssh_user = options.fetch(:ssh_user, 'root')
105
108
  @ssh_port = options.fetch(:ssh_port, 22)
106
- @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
107
- @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
108
109
  @host_public_key = options.fetch(:host_public_key, nil)
109
- @verify_host = options.fetch(:verify_host, nil)
110
110
  @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
111
111
 
112
112
  @client_private_key_file = settings.ssh_identity_key_file
@@ -116,6 +116,7 @@ module Proxy::RemoteExecution::Ssh::Runners
116
116
  @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
117
117
  @first_execution = options.fetch(:first_execution, false)
118
118
  @user_method = user_method
119
+ @options = options
119
120
  end
120
121
 
121
122
  def self.build(options, suspended_action:)
@@ -143,7 +144,9 @@ module Proxy::RemoteExecution::Ssh::Runners
143
144
 
144
145
  def start
145
146
  Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
146
- establish_connection
147
+ ensure_local_directory(@socket_working_dir)
148
+ @connection = MultiplexedSSHConnection.new(@options.merge(:id => @id), logger: logger)
149
+ @connection.establish!
147
150
  preflight_checks
148
151
  prepare_start
149
152
  script = initialization_script
@@ -172,16 +175,6 @@ module Proxy::RemoteExecution::Ssh::Runners
172
175
  end
173
176
  end
174
177
 
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
-
185
178
  def prepare_start
186
179
  @remote_script = cp_script_to_remote
187
180
  @output_path = File.join(File.dirname(@remote_script), 'output')
@@ -232,11 +225,7 @@ module Proxy::RemoteExecution::Ssh::Runners
232
225
  def close_session
233
226
  raise 'Control socket file does not exist' unless File.exist?(socket_file)
234
227
  @logger.debug("Sending exit request for session #{@ssh_user}@#{@host}")
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!
228
+ @connection.disconnect!
240
229
  end
241
230
 
242
231
  def close
@@ -264,35 +253,10 @@ module Proxy::RemoteExecution::Ssh::Runners
264
253
  @process_manager && @cleanup_working_dirs
265
254
  end
266
255
 
267
- def ssh_options(with_pty = false, quiet: false)
268
- ssh_options = []
269
- ssh_options << "-tt" if with_pty
270
- ssh_options << "-o User=#{@ssh_user}"
271
- ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
272
- ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
273
- ssh_options << "-o IdentitiesOnly=yes"
274
- ssh_options << "-o StrictHostKeyChecking=no"
275
- ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
276
- ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
277
- ssh_options << "-o NumberOfPasswordPrompts=1"
278
- ssh_options << "-o LogLevel=#{quiet ? 'quiet' : settings[:ssh_log_level]}"
279
- ssh_options << "-o ControlMaster=auto"
280
- ssh_options << "-o ControlPath=#{socket_file}"
281
- ssh_options << "-o ControlPersist=yes"
282
- ssh_options << "-o ProxyCommand=none"
283
- end
284
-
285
256
  def settings
286
257
  Proxy::RemoteExecution::Ssh::Plugin.settings
287
258
  end
288
259
 
289
- def get_args(command, with_pty = false, quiet: false)
290
- args = []
291
- args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
292
- args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
293
- args += ['/usr/bin/ssh', @host, ssh_options(with_pty, quiet: quiet), command].flatten
294
- end
295
-
296
260
  # Initiates run of the remote command and yields the data when
297
261
  # available. The yielding doesn't happen automatically, but as
298
262
  # part of calling the `refresh` method.
@@ -300,7 +264,9 @@ module Proxy::RemoteExecution::Ssh::Runners
300
264
  raise 'Async command already in progress' if @process_manager&.started?
301
265
 
302
266
  @user_method.reset
303
- initialize_command(*get_args(command, true, quiet: true))
267
+ cmd = @connection.command([tty_flag(true), command].flatten.compact)
268
+ log_command(cmd)
269
+ initialize_command(*cmd)
304
270
 
305
271
  true
306
272
  end
@@ -309,17 +275,15 @@ module Proxy::RemoteExecution::Ssh::Runners
309
275
  @process_manager&.started? && @user_method.sent_all_data?
310
276
  end
311
277
 
278
+ def tty_flag(tty)
279
+ '-tt' if tty
280
+ end
281
+
312
282
  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
- data
320
- end
321
- pm.on_stdout(&callback)
322
- pm.on_stderr(&callback)
283
+ cmd = @connection.command([tty_flag(tty), command].flatten.compact)
284
+ log_command(cmd)
285
+ pm = Proxy::Dynflow::ProcessManager.new(cmd)
286
+ set_pm_debug_logging(pm, user_method: user_method)
323
287
  pm.start!
324
288
  unless pm.status
325
289
  pm.stdin.io.puts(stdin) if stdin
@@ -429,13 +393,6 @@ module Proxy::RemoteExecution::Ssh::Runners
429
393
  @expecting_disconnect = true
430
394
  end
431
395
  end
432
-
433
- def available_authentication_methods
434
- methods = %w[publickey] # Always use pubkey auth as fallback
435
- methods << 'gssapi-with-mic' if settings[:kerberos_auth]
436
- methods.unshift('password') if @ssh_password
437
- methods
438
- end
439
396
  end
440
397
  # rubocop:enable Metrics/ClassLength
441
398
  end
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.7.3'
4
+ VERSION = '0.8.0'
5
5
  end
6
6
  end
7
7
  end
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.7.3
4
+ version: 0.8.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-09-27 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
@@ -170,10 +170,12 @@ files:
170
170
  - lib/smart_proxy_remote_execution_ssh/async_scripts/control.sh
171
171
  - lib/smart_proxy_remote_execution_ssh/async_scripts/retrieve.sh
172
172
  - lib/smart_proxy_remote_execution_ssh/cockpit.rb
173
+ - lib/smart_proxy_remote_execution_ssh/command_logging.rb
173
174
  - lib/smart_proxy_remote_execution_ssh/dispatcher.rb
174
175
  - lib/smart_proxy_remote_execution_ssh/http_config.ru
175
176
  - lib/smart_proxy_remote_execution_ssh/job_storage.rb
176
177
  - lib/smart_proxy_remote_execution_ssh/log_filter.rb
178
+ - lib/smart_proxy_remote_execution_ssh/multiplexed_ssh_connection.rb
177
179
  - lib/smart_proxy_remote_execution_ssh/net_ssh_compat.rb
178
180
  - lib/smart_proxy_remote_execution_ssh/plugin.rb
179
181
  - lib/smart_proxy_remote_execution_ssh/runners.rb
@@ -203,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
203
205
  - !ruby/object:Gem::Version
204
206
  version: '0'
205
207
  requirements: []
206
- rubygems_version: 3.3.20
208
+ rubygems_version: 3.2.26
207
209
  signing_key:
208
210
  specification_version: 4
209
211
  summary: Ssh remote execution provider for Foreman Smart-Proxy