smart_proxy_remote_execution_ssh 0.5.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: abf6517f6a98386e246650454c7deb049b7583c7653040eb63bbdf8e3cc27f0a
4
- data.tar.gz: 12b0aa0d067e45c73ee65286f69d1556f7ae8b1c47624845e1e0a7e96c341e95
3
+ metadata.gz: 1a599d3732d4f6b064a850ae8baced28354980096785a340828533f953f844c4
4
+ data.tar.gz: 160b80ab983f0eb7b987987191b48c19db42d3b76079b0ba6639ec831dccc6f3
5
5
  SHA512:
6
- metadata.gz: f873d27dc9b5ba0b9c10d7d227058618d901a48643ba31e491e1530b34356aa88ac8a197e9731d83a2a535ffa7577ba674578af8b90de3d02f7537d6359063d5
7
- data.tar.gz: d72dfc490d0f71a076885eeabf435a89bca3658ce31a465666725a19e1d0f3c682d51f899922a2b6dbfdbfe8ee8d880fc0b0d0a13ac2536c5ce8f276f64b059b
6
+ metadata.gz: 349961aa474809225d9781bda09687eb23f00db51cca9f758a57e075fa3d19fc07b729569b064887f91bb7f07190152d6ec9cb01e8ccf48df0841e9c3f85d10e
7
+ data.tar.gz: 8ad40859c92f7d1cd089adf0f37bb0aaa8add745cd461c2e1ed5b484d3ae6b856ac0ee53e61c62e89d564a4ba676b22bf5f01618da970986cd60e42591ff6346
@@ -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|
@@ -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,19 +176,22 @@ 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)
182
184
  err_buf_raw = ''
183
- readers = [buf_socket, out_buf, err_buf]
184
185
  loop do
186
+ readers = [buf_socket, out_buf, err_buf].reject { |io| io.closed? }
187
+ writers = [buf_socket, in_buf].select { |io| io.pending_writes? }
185
188
  # Prime the sockets for reading
186
- ready_readers, ready_writers = IO.select(readers, [buf_socket, in_buf], nil, 300)
189
+ ready_readers, ready_writers = IO.select(readers, writers)
187
190
  (ready_readers || []).each { |reader| reader.close if reader.fill.zero? }
188
191
 
189
192
  proxy_data(out_buf, in_buf)
190
193
  if buf_socket.closed?
191
- script_runner.close_session
194
+ connection.disconnect!
192
195
  end
193
196
 
194
197
  if out_buf.closed?
@@ -262,10 +265,10 @@ module Proxy::RemoteExecution
262
265
  params["hostname"]
263
266
  end
264
267
 
265
- def script_runner
266
- @script_runner ||= Proxy::RemoteExecution::Ssh::Runners::ScriptRunner.build(
268
+ def connection
269
+ @connection ||= Proxy::RemoteExecution::Ssh::Runners::MultiplexedSSHConnection.new(
267
270
  runner_params,
268
- suspended_action: nil
271
+ logger: logger
269
272
  )
270
273
  end
271
274
 
@@ -278,6 +281,7 @@ module Proxy::RemoteExecution
278
281
  # For compatibility only
279
282
  ret[:script] = nil
280
283
  ret[:hostname] = host
284
+ ret[:id] = SecureRandom.uuid
281
285
  ret
282
286
  end
283
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
@@ -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
 
@@ -1,7 +1,11 @@
1
1
  module Proxy::RemoteExecution::Ssh
2
2
  class Plugin < Proxy::Plugin
3
3
  SSH_LOG_LEVELS = %w[debug info error fatal].freeze
4
- MODES = %i[ssh async-ssh pull pull-mqtt].freeze
4
+ MODES = %i[ssh ssh-async 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
53
+ @connection.establish! unless @connection.connected?
55
54
  begin
56
- _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
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)
57
57
  rescue StandardError => e
58
58
  @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
59
59
  end
60
-
61
- process_retrieved_data(output, err)
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,11 +131,15 @@ 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
134
- if @session
142
+ if @connection.connected?
135
143
  @logger.debug("Closing session with #{@ssh_user}@#{@host}")
136
144
  close_session
137
145
  end
@@ -1,5 +1,7 @@
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'
4
+ require 'smart_proxy_remote_execution_ssh/multiplexed_ssh_connection'
3
5
 
4
6
  module Proxy::RemoteExecution::Ssh::Runners
5
7
  class EffectiveUserMethod
@@ -12,9 +14,9 @@ module Proxy::RemoteExecution::Ssh::Runners
12
14
  @password_sent = false
13
15
  end
14
16
 
15
- def on_data(received_data, ssh_channel)
17
+ def on_data(received_data, io_buffer)
16
18
  if received_data.match(login_prompt)
17
- ssh_channel.puts(effective_user_password)
19
+ io_buffer.add_data(effective_user_password + "\n")
18
20
  @password_sent = true
19
21
  end
20
22
  end
@@ -90,7 +92,9 @@ module Proxy::RemoteExecution::Ssh::Runners
90
92
 
91
93
  # rubocop:disable Metrics/ClassLength
92
94
  class ScriptRunner < Proxy::Dynflow::Runner::Base
93
- include Proxy::Dynflow::Runner::Command
95
+ include Proxy::Dynflow::Runner::ProcessManagerCommand
96
+ include CommandLogging
97
+
94
98
  attr_reader :execution_timeout_interval
95
99
 
96
100
  EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
@@ -102,18 +106,17 @@ module Proxy::RemoteExecution::Ssh::Runners
102
106
  @script = options.fetch(:script)
103
107
  @ssh_user = options.fetch(:ssh_user, 'root')
104
108
  @ssh_port = options.fetch(:ssh_port, 22)
105
- @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
106
- @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
107
109
  @host_public_key = options.fetch(:host_public_key, nil)
108
- @verify_host = options.fetch(:verify_host, nil)
109
110
  @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
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
119
+ @options = options
117
120
  end
118
121
 
119
122
  def self.build(options, suspended_action:)
@@ -141,6 +144,10 @@ module Proxy::RemoteExecution::Ssh::Runners
141
144
 
142
145
  def start
143
146
  Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
147
+ ensure_local_directory(@socket_working_dir)
148
+ @connection = MultiplexedSSHConnection.new(@options.merge(:id => @id), logger: logger)
149
+ @connection.establish!
150
+ preflight_checks
144
151
  prepare_start
145
152
  script = initialization_script
146
153
  logger.debug("executing script:\n#{indent_multiline(script)}")
@@ -154,32 +161,51 @@ module Proxy::RemoteExecution::Ssh::Runners
154
161
  run_async(*args)
155
162
  end
156
163
 
164
+ def preflight_checks
165
+ ensure_remote_command(cp_script_to_remote("#!/bin/sh\nexec true", 'test'),
166
+ error: 'Failed to execute script on remote machine, exit code: %{exit_code}.'
167
+ )
168
+ unless @user_method.is_a? NoopUserMethod
169
+ path = cp_script_to_remote("#!/bin/sh\nexec #{@user_method.cli_command_prefix} true", 'effective-user-test')
170
+ ensure_remote_command(path,
171
+ error: 'Failed to change to effective user, exit code: %{exit_code}',
172
+ tty: true,
173
+ user_method: @user_method,
174
+ close_stdin: false)
175
+ end
176
+ end
177
+
157
178
  def prepare_start
158
179
  @remote_script = cp_script_to_remote
159
180
  @output_path = File.join(File.dirname(@remote_script), 'output')
160
181
  @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
182
+ @pid_path = File.join(File.dirname(@remote_script), 'pid')
183
+ @remote_script_wrapper = upload_data("echo $$ > #{@pid_path}; exec \"$@\";", File.join(File.dirname(@remote_script), 'script-wrapper'), 555)
161
184
  end
162
185
 
163
186
  # the script that initiates the execution
164
187
  def initialization_script
165
188
  su_method = @user_method.instance_of?(SuUserMethod)
166
189
  # 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})"
190
+ <<~SCRIPT
191
+ sh <<EOF | /usr/bin/tee #{@output_path}
192
+ #{@remote_script_wrapper} #{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}
193
+ echo \\$?>#{@exit_code_path}
194
+ EOF
195
+ exit $(cat #{@exit_code_path})
170
196
  SCRIPT
171
197
  end
172
198
 
173
199
  def refresh
174
- return if @session.nil?
200
+ return if @process_manager.nil?
175
201
  super
176
202
  ensure
177
203
  check_expecting_disconnect
178
204
  end
179
205
 
180
206
  def kill
181
- if @session
182
- run_sync("pkill -f #{remote_command_file('script')}")
207
+ if @process_manager&.started?
208
+ run_sync("pkill -P $(cat #{@pid_path})")
183
209
  else
184
210
  logger.debug('connection closed')
185
211
  end
@@ -197,28 +223,24 @@ module Proxy::RemoteExecution::Ssh::Runners
197
223
  end
198
224
 
199
225
  def close_session
200
- @session = nil
201
- raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
226
+ raise 'Control socket file does not exist' unless File.exist?(socket_file)
202
227
  @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
228
+ @connection.disconnect!
208
229
  end
209
230
 
210
231
  def close
211
- run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
232
+ run_sync("rm -rf #{remote_command_dir}") if should_cleanup?
212
233
  rescue StandardError => e
213
234
  publish_exception('Error when removing remote working dir', e, false)
214
235
  ensure
215
- close_session if @session
236
+ close_session if @process_manager
216
237
  FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
217
238
  end
218
239
 
219
- def publish_data(data, type)
240
+ def publish_data(data, type, pm = nil)
241
+ pm ||= @process_manager
220
242
  super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
221
- @user_method.on_data(data, @command_in)
243
+ @user_method.on_data(data, pm.stdin) if pm
222
244
  end
223
245
 
224
246
  private
@@ -228,94 +250,47 @@ module Proxy::RemoteExecution::Ssh::Runners
228
250
  end
229
251
 
230
252
  def should_cleanup?
231
- @session && @cleanup_working_dirs
232
- end
233
-
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)
252
- ssh_options = []
253
- ssh_options << "-tt" if with_pty
254
- ssh_options << "-o User=#{@ssh_user}"
255
- ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
256
- ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
257
- ssh_options << "-o IdentitiesOnly=yes"
258
- ssh_options << "-o StrictHostKeyChecking=no"
259
- ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
260
- ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
261
- ssh_options << "-o NumberOfPasswordPrompts=1"
262
- ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
263
- ssh_options << "-o ControlMaster=auto"
264
- ssh_options << "-o ControlPath=#{local_command_file("socket")}"
265
- ssh_options << "-o ControlPersist=yes"
253
+ @process_manager && @cleanup_working_dirs
266
254
  end
267
255
 
268
256
  def settings
269
257
  Proxy::RemoteExecution::Ssh::Plugin.settings
270
258
  end
271
259
 
272
- def get_args(command, with_pty = false)
273
- args = []
274
- args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
275
- args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
276
- args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
277
- end
278
-
279
260
  # Initiates run of the remote command and yields the data when
280
261
  # available. The yielding doesn't happen automatically, but as
281
262
  # part of calling the `refresh` method.
282
263
  def run_async(command)
283
- raise 'Async command already in progress' if @started
264
+ raise 'Async command already in progress' if @process_manager&.started?
284
265
 
285
- @started = false
286
266
  @user_method.reset
287
- @command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
288
- @started = true
267
+ cmd = @connection.command([tty_flag(true), command].flatten.compact)
268
+ log_command(cmd)
269
+ initialize_command(*cmd)
289
270
 
290
- return true
271
+ true
291
272
  end
292
273
 
293
274
  def run_started?
294
- @started && @user_method.sent_all_data?
275
+ @process_manager&.started? && @user_method.sent_all_data?
295
276
  end
296
277
 
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
304
- 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
278
+ def tty_flag(tty)
279
+ '-tt' if tty
310
280
  end
311
281
 
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
282
+ def run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil)
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)
287
+ pm.start!
288
+ unless pm.status
289
+ pm.stdin.io.puts(stdin) if stdin
290
+ pm.stdin.io.close if close_stdin
291
+ pm.run!
292
+ end
293
+ pm
319
294
  end
320
295
 
321
296
  def prepare_known_hosts
@@ -334,6 +309,10 @@ module Proxy::RemoteExecution::Ssh::Runners
334
309
  File.join(ensure_local_directory(local_command_dir), filename)
335
310
  end
336
311
 
312
+ def socket_file
313
+ File.join(ensure_local_directory(@socket_working_dir), @id)
314
+ end
315
+
337
316
  def remote_command_dir
338
317
  File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
339
318
  end
@@ -362,15 +341,13 @@ module Proxy::RemoteExecution::Ssh::Runners
362
341
  # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
363
342
  # This is used to write to $path with elevated permissions, solutions using cat and output redirection
364
343
  # would not work, because the redirection would happen in the non-elevated shell.
365
- command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
344
+ command = "tee #{path} >/dev/null && chmod #{permissions} #{path}"
366
345
 
367
346
  @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
347
+ ensure_remote_command(command,
348
+ stdin: data,
349
+ error: "Unable to upload file to #{path} on remote system, exit code: %{exit_code}"
350
+ )
374
351
 
375
352
  path
376
353
  end
@@ -382,9 +359,15 @@ module Proxy::RemoteExecution::Ssh::Runners
382
359
  end
383
360
 
384
361
  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}"
362
+ ensure_remote_command("mkdir -p #{path}",
363
+ error: "Unable to create directory #{path} on remote system, exit code: %{exit_code}"
364
+ )
365
+ end
366
+
367
+ def ensure_remote_command(cmd, error: nil, **kwargs)
368
+ if (pm = run_sync(cmd, **kwargs)).status != 0
369
+ msg = error || 'Failed to run command %{command} on remote machine, exit code: %{exit_code}'
370
+ raise(msg % { command: cmd, exit_code: pm.status })
388
371
  end
389
372
  end
390
373
 
@@ -410,13 +393,6 @@ module Proxy::RemoteExecution::Ssh::Runners
410
393
  @expecting_disconnect = true
411
394
  end
412
395
  end
413
-
414
- def available_authentication_methods
415
- methods = %w[publickey] # Always use pubkey auth as fallback
416
- methods << 'gssapi-with-mic' if settings[:kerberos_auth]
417
- methods.unshift('password') if @ssh_password
418
- methods
419
- end
420
396
  end
421
397
  # rubocop:enable Metrics/ClassLength
422
398
  end
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.5.3'
4
+ VERSION = '0.8.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.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-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
@@ -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.1.4
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