smart_proxy_remote_execution_ssh 0.4.0 → 0.5.2

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: 750910e916f0d4ad411cf868636075e597573dd8cca720e414156bcee55331dc
4
- data.tar.gz: 4003e71f358abc47847fb9a2e4cf3c211189bc915b6b4196dd02d6d21633d2b8
3
+ metadata.gz: 1ba2217281e0dfb1199d345fd81e2e135c3b3f83e590177ecc8826dfb8176262
4
+ data.tar.gz: db341f9a3cbed3045938d16cc0b772887098ee1db4adc2d4b515449785afbf39
5
5
  SHA512:
6
- metadata.gz: efa2a87ce6a6125f7701979305a0c5fcc1fb3ad2703d5aef140505943789b45648311e46f892791a919a88d757f019485ff45209efc22423cc6543a298224a03
7
- data.tar.gz: 4411e680ca841903d47b295090c4a194f92dfe91cc58796272d42b9c370ad6a3e6a8bc34973f0d7a85d93fe604788993428d944277b05426194893ecff0a9d51
6
+ metadata.gz: 4564886d08716efa9f7d40b887027d67dcd2bb9b02688033cd6f9f7f5a633a2e215ff5e567e144db687f7bb51f624b19aa240159c0f2a15ccaccbe983161c28a
7
+ data.tar.gz: fb3e8fd7bdba9193731c1399f2809e5625c6313b9cf1a1d1a6daad1e2bfc012e7057ebc7a3766e9d4bf9e1938b898dabfeea042f87db2d31b20dd07964a6744e
@@ -0,0 +1,110 @@
1
+ require 'mqtt'
2
+ require 'json'
3
+
4
+ module Proxy::RemoteExecution::Ssh::Actions
5
+ class PullScript < Proxy::Dynflow::Action::Runner
6
+ JobDelivered = Class.new
7
+
8
+ execution_plan_hooks.use :cleanup, :on => :stopped
9
+
10
+ def plan(action_input, mqtt: false)
11
+ super(action_input)
12
+ input[:with_mqtt] = mqtt
13
+ end
14
+
15
+ def run(event = nil)
16
+ if event == JobDelivered
17
+ output[:state] = :delivered
18
+ suspend
19
+ else
20
+ super
21
+ end
22
+ end
23
+
24
+ def init_run
25
+ otp_password = if input[:with_mqtt]
26
+ ::Proxy::Dynflow::OtpManager.generate_otp(execution_plan_id)
27
+ end
28
+ input[:job_uuid] = job_storage.store_job(host_name, execution_plan_id, run_step_id, input[:script])
29
+ output[:state] = :ready_for_pickup
30
+ output[:result] = []
31
+ mqtt_start(otp_password) if input[:with_mqtt]
32
+ suspend
33
+ end
34
+
35
+ def cleanup(_plan = nil)
36
+ job_storage.drop_job(execution_plan_id, run_step_id)
37
+ Proxy::Dynflow::OtpManager.passwords.delete(execution_plan_id)
38
+ end
39
+
40
+ def process_external_event(event)
41
+ output[:state] = :running
42
+ data = event.data
43
+ 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']
46
+ process_update(Proxy::Dynflow::Runner::Update.new(continuous_output, exit_code))
47
+ end
48
+
49
+ def kill_run
50
+ case output[:state]
51
+ when :ready_for_pickup
52
+ # If the job is not running yet on the client, wipe it from storage
53
+ cleanup
54
+ # TODO: Stop the action
55
+ when :notified, :running
56
+ # Client was notified or is already running, dealing with this situation
57
+ # is only supported if mqtt is available
58
+ # Otherwise we have to wait it out
59
+ # TODO
60
+ # if input[:with_mqtt]
61
+ end
62
+ suspend
63
+ end
64
+
65
+ 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',
72
+ metadata: {
73
+ 'job_uuid': input[:job_uuid],
74
+ 'username': execution_plan_id,
75
+ 'password': otp_password,
76
+ 'return_url': "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}/update",
77
+ },
78
+ content: "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}",
79
+ }
80
+ mqtt_notify payload
81
+ output[:state] = :notified
82
+ end
83
+
84
+ def mqtt_notify(payload)
85
+ MQTT::Client.connect(settings.mqtt_broker, settings.mqtt_port) do |c|
86
+ c.publish(mqtt_topic, JSON.dump(payload), false, 1)
87
+ end
88
+ end
89
+
90
+ def host_name
91
+ alternative_names = input.fetch(:alternative_names, {})
92
+
93
+ alternative_names[:consumer_uuid] ||
94
+ alternative_names[:fqdn] ||
95
+ input[:hostname]
96
+ end
97
+
98
+ def mqtt_topic
99
+ "yggdrasil/#{host_name}/data/in"
100
+ end
101
+
102
+ def settings
103
+ Proxy::RemoteExecution::Ssh::Plugin.settings
104
+ end
105
+
106
+ def job_storage
107
+ Proxy::RemoteExecution::Ssh.job_storage
108
+ end
109
+ end
110
+ end
@@ -1,8 +1,22 @@
1
- require 'foreman_tasks_core/shareable_action'
1
+ require 'smart_proxy_dynflow/action/shareable'
2
+ require 'smart_proxy_dynflow/action/runner'
2
3
 
3
4
  module Proxy::RemoteExecution::Ssh
4
5
  module Actions
5
- class RunScript < ForemanTasksCore::Runner::Action
6
+ class RunScript < ::Dynflow::Action
7
+ def plan(*args)
8
+ mode = Proxy::RemoteExecution::Ssh::Plugin.settings.mode
9
+ case mode
10
+ when :ssh, :'ssh-async'
11
+ plan_action(ScriptRunner, *args)
12
+ when :pull, :'pull-mqtt'
13
+ plan_action(PullScript, *args,
14
+ mqtt: mode == :'pull-mqtt')
15
+ end
16
+ end
17
+ end
18
+
19
+ class ScriptRunner < Proxy::Dynflow::Action::Runner
6
20
  def initiate_runner
7
21
  additional_options = {
8
22
  :step_id => run_step_id,
@@ -0,0 +1,6 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ module Actions
3
+ require 'smart_proxy_remote_execution_ssh/actions/run_script'
4
+ require 'smart_proxy_remote_execution_ssh/actions/pull_script'
5
+ end
6
+ end
@@ -1,11 +1,13 @@
1
1
  require 'net/ssh'
2
2
  require 'base64'
3
+ require 'smart_proxy_dynflow/runner'
3
4
 
4
5
  module Proxy::RemoteExecution
5
6
  module Ssh
6
7
 
7
8
  class Api < ::Sinatra::Base
8
9
  include Sinatra::Authorization::Helpers
10
+ include Proxy::Dynflow::Helpers
9
11
 
10
12
  get "/pubkey" do
11
13
  File.read(Ssh.public_key_file)
@@ -37,6 +39,53 @@ module Proxy::RemoteExecution
37
39
  end
38
40
  204
39
41
  end
42
+
43
+ # Payload is a hash where
44
+ # exit_code: Integer | NilClass
45
+ # output: String
46
+ post '/jobs/:job_uuid/update' do |job_uuid|
47
+ do_authorize_with_ssl_client
48
+
49
+ with_authorized_job(job_uuid) do |job_record|
50
+ data = MultiJson.load(request.body.read)
51
+ notify_job(job_record, ::Proxy::Dynflow::Runner::ExternalEvent.new(data))
52
+ end
53
+ end
54
+
55
+ get '/jobs' do
56
+ do_authorize_with_ssl_client
57
+
58
+ MultiJson.dump(Proxy::RemoteExecution::Ssh.job_storage.job_uuids_for_host(https_cert_cn))
59
+ end
60
+
61
+ get "/jobs/:job_uuid" do |job_uuid|
62
+ do_authorize_with_ssl_client
63
+
64
+ with_authorized_job(job_uuid) do |job_record|
65
+ notify_job(job_record, Actions::PullScript::JobDelivered)
66
+ job_record[:job]
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def notify_job(job_record, event)
73
+ world.event(job_record[:execution_plan_uuid], job_record[:run_step_id], event)
74
+ end
75
+
76
+ def with_authorized_job(uuid)
77
+ if (job = authorized_job(uuid))
78
+ yield job
79
+ else
80
+ halt 404
81
+ end
82
+ end
83
+
84
+ def authorized_job(uuid)
85
+ job_record = Proxy::RemoteExecution::Ssh.job_storage.find_job(uuid) || {}
86
+ return job_record if authorize_with_token(clear: false, task_id: job_record[:execution_plan_uuid]) ||
87
+ job_record[:hostname] == https_cert_cn
88
+ end
40
89
  end
41
90
  end
42
91
  end
@@ -1,11 +1,11 @@
1
- require 'net/ssh'
1
+ require 'smart_proxy_remote_execution_ssh/net_ssh_compat'
2
2
  require 'forwardable'
3
3
 
4
4
  module Proxy::RemoteExecution
5
5
  module Cockpit
6
6
  # A wrapper class around different kind of sockets to comply with Net::SSH event loop
7
7
  class BufferedSocket
8
- include Net::SSH::BufferedIo
8
+ include Proxy::RemoteExecution::NetSSHCompat::BufferedIO
9
9
  extend Forwardable
10
10
 
11
11
  # The list of methods taken from OpenSSL::SSL::SocketForwarder for the object to act like a socket
@@ -52,14 +52,14 @@ module Proxy::RemoteExecution
52
52
  end
53
53
  def_delegators(:@socket, :read_nonblock, :write_nonblock, :close)
54
54
 
55
- def recv(n)
55
+ def recv(count)
56
56
  res = ""
57
57
  begin
58
58
  # To drain a SSLSocket before we can go back to the event
59
59
  # loop, we need to repeatedly call read_nonblock; a single
60
60
  # call is not enough.
61
61
  loop do
62
- res += @socket.read_nonblock(n)
62
+ res += @socket.read_nonblock(count)
63
63
  end
64
64
  rescue IO::WaitReadable
65
65
  # Sometimes there is no payload after reading everything
@@ -95,8 +95,8 @@ module Proxy::RemoteExecution
95
95
  end
96
96
  def_delegators(:@socket, :read_nonblock, :write_nonblock, :close)
97
97
 
98
- def recv(n)
99
- @socket.read_nonblock(n)
98
+ def recv(count)
99
+ @socket.read_nonblock(count)
100
100
  end
101
101
 
102
102
  def send(mesg, flags)
@@ -113,6 +113,7 @@ module Proxy::RemoteExecution
113
113
 
114
114
  def initialize(env)
115
115
  @env = env
116
+ @open_ios = []
116
117
  end
117
118
 
118
119
  def valid?
@@ -127,6 +128,7 @@ module Proxy::RemoteExecution
127
128
  begin
128
129
  @env['rack.hijack'].call
129
130
  rescue NotImplementedError
131
+ # This is fine
130
132
  end
131
133
  @socket = @env['rack.hijack_io']
132
134
  end
@@ -137,15 +139,11 @@ module Proxy::RemoteExecution
137
139
  private
138
140
 
139
141
  def ssh_on_socket
140
- with_error_handling { start_ssh_loop }
142
+ with_error_handling { system_ssh_loop }
141
143
  end
142
144
 
143
145
  def with_error_handling
144
146
  yield
145
- rescue Net::SSH::AuthenticationFailed => e
146
- send_error(401, e.message)
147
- rescue Errno::EHOSTUNREACH
148
- send_error(400, "No route to #{host}")
149
147
  rescue SystemCallError => e
150
148
  send_error(400, e.message)
151
149
  rescue SocketError => e
@@ -161,50 +159,67 @@ module Proxy::RemoteExecution
161
159
  end
162
160
  end
163
161
 
164
- def start_ssh_loop
165
- err_buf = ""
166
-
167
- Net::SSH.start(host, ssh_user, ssh_options) do |ssh|
168
- channel = ssh.open_channel do |ch|
169
- ch.exec(command) do |ch, success|
170
- raise "could not execute command" unless success
171
-
172
- ssh.listen_to(buf_socket)
173
-
174
- ch.on_process do
175
- if buf_socket.available.positive?
176
- ch.send_data(buf_socket.read_available)
177
- end
178
- if buf_socket.closed?
179
- ch.close
180
- end
181
- end
182
-
183
- ch.on_data do |ch2, data|
184
- send_start
185
- buf_socket.enqueue(data)
186
- end
187
-
188
- ch.on_request('exit-status') do |ch, data|
189
- code = data.read_long
190
- send_start if code.zero?
191
- err_buf += "Process exited with code #{code}.\r\n"
192
- ch.close
193
- end
194
-
195
- ch.on_request('exit-signal') do |ch, data|
196
- err_buf += "Process was terminated with signal #{data.read_string}.\r\n"
197
- ch.close
198
- end
199
-
200
- ch.on_extended_data do |ch2, type, data|
201
- err_buf += data
202
- end
203
- end
162
+ def system_ssh_loop
163
+ in_read, in_write = IO.pipe
164
+ out_read, out_write = IO.pipe
165
+ err_read, err_write = IO.pipe
166
+
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)
170
+ [in_read, out_write, err_write].each(&:close)
171
+
172
+ send_start
173
+ # Not SSL buffer, but the interface kinda matches
174
+ out_buf = MiniSSLBufferedSocket.new(out_read)
175
+ err_buf = MiniSSLBufferedSocket.new(err_read)
176
+ in_buf = MiniSSLBufferedSocket.new(in_write)
177
+
178
+ inner_system_ssh_loop out_buf, err_buf, in_buf, pid
179
+ end
180
+
181
+ def inner_system_ssh_loop(out_buf, err_buf, in_buf, pid)
182
+ err_buf_raw = ''
183
+ readers = [buf_socket, out_buf, err_buf]
184
+ loop do
185
+ # Prime the sockets for reading
186
+ ready_readers, ready_writers = IO.select(readers, [buf_socket, in_buf], nil, 300)
187
+ (ready_readers || []).each { |reader| reader.close if reader.fill.zero? }
188
+
189
+ proxy_data(out_buf, in_buf)
190
+ if buf_socket.closed?
191
+ script_runner.close_session
204
192
  end
205
193
 
206
- channel.wait
207
- send_error(400, err_buf) unless @started
194
+ if out_buf.closed?
195
+ code = Process.wait2(pid).last.exitstatus
196
+ send_start if code.zero? # TODO: Why?
197
+ err_buf_raw += "Process exited with code #{code}.\r\n"
198
+ break
199
+ end
200
+
201
+ if err_buf.available.positive?
202
+ err_buf_raw += err_buf.read_available
203
+ end
204
+
205
+ flush_pending_writes(ready_writers || [])
206
+ end
207
+ rescue # rubocop:disable Style/RescueStandardError
208
+ send_error(400, err_buf_raw) unless @started
209
+ ensure
210
+ [out_buf, err_buf, in_buf].each(&:close)
211
+ end
212
+
213
+ def proxy_data(out_buf, in_buf)
214
+ { out_buf => buf_socket, buf_socket => in_buf }.each do |src, dst|
215
+ dst.enqueue(src.read_available) if src.available.positive?
216
+ dst.close if src.closed?
217
+ end
218
+ end
219
+
220
+ def flush_pending_writes(writers)
221
+ writers.each do |writer|
222
+ writer.respond_to?(:send_pending) ? writer.send_pending : writer.flush
208
223
  end
209
224
  end
210
225
 
@@ -215,6 +230,7 @@ module Proxy::RemoteExecution
215
230
  buf_socket.enqueue("Connection: upgrade\r\n")
216
231
  buf_socket.enqueue("Upgrade: raw\r\n")
217
232
  buf_socket.enqueue("\r\n")
233
+ buf_socket.send_pending
218
234
  end
219
235
  end
220
236
 
@@ -223,6 +239,7 @@ module Proxy::RemoteExecution
223
239
  buf_socket.enqueue("Connection: close\r\n")
224
240
  buf_socket.enqueue("\r\n")
225
241
  buf_socket.enqueue(msg)
242
+ buf_socket.send_pending
226
243
  end
227
244
 
228
245
  def params
@@ -234,34 +251,33 @@ module Proxy::RemoteExecution
234
251
  end
235
252
 
236
253
  def buf_socket
237
- @buffered_socket ||= BufferedSocket.build(@socket)
254
+ @buf_socket ||= BufferedSocket.build(@socket)
238
255
  end
239
256
 
240
257
  def command
241
258
  params["command"]
242
259
  end
243
260
 
244
- def ssh_user
245
- params["ssh_user"]
246
- end
247
-
248
261
  def host
249
262
  params["hostname"]
250
263
  end
251
264
 
252
- def ssh_options
253
- auth_methods = %w[publickey]
254
- auth_methods.unshift('password') if params["ssh_password"]
255
-
256
- ret = {}
257
- ret[:port] = params["ssh_port"] if params["ssh_port"]
258
- ret[:keys] = [key_file] if key_file
259
- ret[:password] = params["ssh_password"] if params["ssh_password"]
260
- ret[:passphrase] = params[:ssh_key_passphrase] if params[:ssh_key_passphrase]
261
- ret[:keys_only] = true
262
- ret[:auth_methods] = auth_methods
263
- ret[:verify_host_key] = true
264
- ret[:number_of_password_prompts] = 1
265
+ def script_runner
266
+ @script_runner ||= Proxy::RemoteExecution::Ssh::Runners::ScriptRunner.build(
267
+ runner_params,
268
+ suspended_action: nil
269
+ )
270
+ end
271
+
272
+ def runner_params
273
+ ret = { secrets: {} }
274
+ ret[:secrets][:ssh_password] = params["ssh_password"] if params["ssh_password"]
275
+ ret[:secrets][:key_passphrase] = params["ssh_key_passphrase"] if params["ssh_key_passphrase"]
276
+ ret[:ssh_port] = params["ssh_port"] if params["ssh_port"]
277
+ ret[:ssh_user] = params["ssh_user"]
278
+ # For compatibility only
279
+ ret[:script] = nil
280
+ ret[:hostname] = host
265
281
  ret
266
282
  end
267
283
  end
@@ -1,7 +1,7 @@
1
- require 'foreman_tasks_core/runner/dispatcher'
1
+ require 'smart_proxy_dynflow/runner/dispatcher'
2
2
 
3
3
  module Proxy::RemoteExecution::Ssh
4
- class Dispatcher < ::ForemanTasksCore::Runner::Dispatcher
4
+ class Dispatcher < ::Proxy::Dynflow::Runner::Dispatcher
5
5
  def refresh_interval
6
6
  @refresh_interval ||= Plugin.settings[:runner_refresh_interval] ||
7
7
  Plugin.runner_class::DEFAULT_REFRESH_INTERVAL
@@ -0,0 +1,51 @@
1
+ # lib/job_storage.rb
2
+ require 'sequel'
3
+
4
+ module Proxy::RemoteExecution::Ssh
5
+ class JobStorage
6
+ def initialize
7
+ @db = Sequel.sqlite
8
+ @db.create_table :jobs do
9
+ DateTime :timestamp, null: false, default: Sequel::CURRENT_TIMESTAMP
10
+ String :uuid, fixed: true, size: 36, primary_key: true, null: false
11
+ String :hostname, null: false, index: true
12
+ String :execution_plan_uuid, fixed: true, size: 36, null: false, index: true
13
+ Integer :run_step_id, null: false
14
+ String :job, text: true
15
+ end
16
+ end
17
+
18
+ def find_job(uuid)
19
+ jobs.where(uuid: uuid).first
20
+ end
21
+
22
+ def job_uuids_for_host(hostname)
23
+ jobs_for_host(hostname).order(:timestamp)
24
+ .select_map(:uuid)
25
+ end
26
+
27
+ def store_job(hostname, execution_plan_uuid, run_step_id, job, uuid: SecureRandom.uuid, timestamp: Time.now.utc)
28
+ jobs.insert(timestamp: timestamp,
29
+ uuid: uuid,
30
+ hostname: hostname,
31
+ execution_plan_uuid: execution_plan_uuid,
32
+ run_step_id: run_step_id,
33
+ job: job)
34
+ uuid
35
+ end
36
+
37
+ def drop_job(execution_plan_uuid, run_step_id)
38
+ jobs.where(execution_plan_uuid: execution_plan_uuid, run_step_id: run_step_id).delete
39
+ end
40
+
41
+ private
42
+
43
+ def jobs_for_host(hostname)
44
+ jobs.where(hostname: hostname)
45
+ end
46
+
47
+ def jobs
48
+ @db[:jobs]
49
+ end
50
+ end
51
+ end