smart_proxy_remote_execution_ssh 0.4.0 → 0.5.2

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: 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