smart_proxy_remote_execution_ssh 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1 @@
1
+ gem 'pry'
@@ -0,0 +1 @@
1
+ gem 'smart_proxy_remote_execution_ssh'
@@ -0,0 +1,44 @@
1
+ require 'smart_proxy_dynflow'
2
+
3
+ require 'smart_proxy_remote_execution_ssh/version'
4
+ require 'smart_proxy_remote_execution_ssh/plugin'
5
+
6
+ require 'smart_proxy_remote_execution_ssh/dispatcher'
7
+ require 'smart_proxy_remote_execution_ssh/command_action'
8
+
9
+ require 'smart_proxy_remote_execution_ssh/api'
10
+
11
+ module Proxy::RemoteExecution
12
+ module Ssh
13
+ class << self
14
+ attr_reader :dispatcher
15
+
16
+ def initialize
17
+ unless private_key_file
18
+ raise "settings for `ssh_identity_key` not set"
19
+ end
20
+
21
+ unless File.exist?(private_key_file)
22
+ raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
23
+ "You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
24
+ end
25
+
26
+ unless File.exist?(public_key_file)
27
+ raise "Ssh public key file #{public_key_file} doesn't exist"
28
+ end
29
+
30
+ @dispatcher = Proxy::RemoteExecution::Ssh::Dispatcher.spawn('proxy-ssh-dispatcher',
31
+ :clock => Proxy::Dynflow.instance.world.clock,
32
+ :logger => Proxy::Dynflow.instance.world.logger)
33
+ end
34
+
35
+ def private_key_file
36
+ File.expand_path(Ssh::Plugin.settings.ssh_identity_key_file)
37
+ end
38
+
39
+ def public_key_file
40
+ File.expand_path("#{private_key_file}.pub")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,9 @@
1
+ module Proxy::RemoteExecution
2
+ module Ssh
3
+ class Api < ::Sinatra::Base
4
+ get "/pubkey" do
5
+ File.read(Ssh.public_key_file)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,75 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ class CommandAction < ::Dynflow::Action
3
+ include Dynflow::Action::Cancellable
4
+ include ::Proxy::Dynflow::Callback::PlanHelper
5
+
6
+ def plan(input)
7
+ if callback = input['callback']
8
+ input[:task_id] = callback['task_id']
9
+ else
10
+ input[:task_id] ||= SecureRandom.uuid
11
+ end
12
+ plan_with_callback(input)
13
+ end
14
+
15
+ def run(event = nil)
16
+ case event
17
+ when nil
18
+ init_run
19
+ when Dispatcher::CommandUpdate
20
+ update = event
21
+ output[:result].concat(update.buffer_to_hash)
22
+ if update.exit_status
23
+ finish_run(update)
24
+ else
25
+ suspend
26
+ end
27
+ when Dynflow::Action::Cancellable::Cancel
28
+ kill_run
29
+ when Dynflow::Action::Skip
30
+ # do nothing
31
+ else
32
+ raise "Unexpected event #{event.inspect}"
33
+ end
34
+ end
35
+
36
+ def finalize
37
+ # To mark the task as a whole as failed
38
+ error! "Script execution failed" if failed_run?
39
+ end
40
+
41
+ def rescue_strategy_for_self
42
+ Dynflow::Action::Rescue::Skip
43
+ end
44
+
45
+ def command
46
+ @command ||= Dispatcher::Command.new(:id => input[:task_id],
47
+ :host => input[:hostname],
48
+ :ssh_user => 'root',
49
+ :effective_user => input[:effective_user],
50
+ :script => input[:script],
51
+ :host_public_key => input[:host_public_key],
52
+ :verify_host => input[:verify_host],
53
+ :suspended_action => suspended_action)
54
+ end
55
+
56
+ def init_run
57
+ output[:result] = []
58
+ Proxy::RemoteExecution::Ssh.dispatcher.tell([:initialize_command, command])
59
+ suspend
60
+ end
61
+
62
+ def kill_run
63
+ Proxy::RemoteExecution::Ssh.dispatcher.tell([:kill, command])
64
+ suspend
65
+ end
66
+
67
+ def finish_run(update)
68
+ output[:exit_status] = update.exit_status
69
+ end
70
+
71
+ def failed_run?
72
+ output[:exit_status] != 0
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,182 @@
1
+ require 'net/ssh'
2
+ require 'net/scp'
3
+
4
+ module Proxy::RemoteExecution::Ssh
5
+ # Service that handles running external commands for Actions::Command
6
+ # Dynflow action. It runs just one (actor) thread for all the commands
7
+ # running in the system and updates the Dynflow actions periodically.
8
+ class Connector
9
+ class Data
10
+ attr_reader :data, :timestamp
11
+
12
+ def initialize(data, timestamp = Time.now)
13
+ @data = data
14
+ @timestamp = timestamp
15
+ end
16
+
17
+ def data_type
18
+ raise NotImplemented
19
+ end
20
+ end
21
+
22
+ class StdoutData < Data
23
+ def data_type
24
+ :stdout
25
+ end
26
+ end
27
+
28
+ class StderrData < Data
29
+ def data_type
30
+ :stderr
31
+ end
32
+ end
33
+
34
+ class DebugData < Data
35
+ def data_type
36
+ :debug
37
+ end
38
+ end
39
+
40
+ class StatusData < Data
41
+ def data_type
42
+ :status
43
+ end
44
+ end
45
+
46
+ MAX_PROCESS_RETRIES = 3
47
+
48
+ def initialize(host, user, options = {})
49
+ @host = host
50
+ @user = user
51
+ @logger = options[:logger] || Logger.new($stderr)
52
+ @client_private_key_file = options[:client_private_key_file]
53
+ @known_hosts_file = options[:known_hosts_file]
54
+ end
55
+
56
+ # Initiates run of the remote command and yields the data when
57
+ # available. The yielding doesn't happen automatically, but as
58
+ # part of calling the `refresh` method.
59
+ def async_run(command)
60
+ started = false
61
+ session.open_channel do |channel|
62
+ channel.on_data { |ch, data| yield StdoutData.new(data) }
63
+
64
+ channel.on_extended_data { |ch, type, data| yield StderrData.new(data) }
65
+
66
+ # standard exit of the command
67
+ channel.on_request("exit-status") { |ch, data| yield StatusData.new(data.read_long) }
68
+
69
+ # on signal: sedning the signal value (such as 'TERM')
70
+ channel.on_request("exit-signal") do |ch, data|
71
+ yield(StatusData.new(data.read_string))
72
+ ch.close
73
+ # wait for the channel to finish so that we know at the end
74
+ # that the session is inactive
75
+ ch.wait
76
+ end
77
+
78
+ channel.exec(command) do |ch, success|
79
+ started = true
80
+ unless success
81
+ yield DebugData.new("FAILED: couldn't execute command (ssh.channel.exec)")
82
+ yield StatusData.new("INIT_ERROR")
83
+ end
84
+ end
85
+ end
86
+ session.process(0) until started
87
+ return true
88
+ end
89
+
90
+ def run(command)
91
+ output = ""
92
+ exit_status = nil
93
+ channel = session.open_channel do |ch|
94
+ ch.on_data { |data| output.concat(data) }
95
+
96
+ ch.on_extended_data { |_, _, data| output.concat(data) }
97
+
98
+ ch.on_request("exit-status") { |_, data| exit_status = data.read_long }
99
+
100
+ # on signal: sedning the signal value (such as 'TERM')
101
+ ch.on_request("exit-signal") do |_, data|
102
+ exit_status = data.read_string
103
+ ch.close
104
+ ch.wait
105
+ end
106
+
107
+ ch.exec command do |_, success|
108
+ raise "could not execute command" unless success
109
+ end
110
+ end
111
+ channel.wait
112
+ return exit_status, output
113
+ end
114
+
115
+ # calls the callback registered in the `async_run` when some data
116
+ # for the session are available
117
+ def refresh
118
+ return if @session.nil?
119
+ tries = 0
120
+ begin
121
+ session.process(0)
122
+ rescue => e
123
+ @logger.error("Error while processing ssh channel: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
124
+ tries += 1
125
+ if tries <= MAX_PROCESS_RETRIES
126
+ retry
127
+ else
128
+ raise e
129
+ end
130
+ end
131
+ end
132
+
133
+ def upload_file(local_path, remote_path)
134
+ ensure_remote_directory(File.dirname(remote_path))
135
+ scp = Net::SCP.new(session)
136
+ upload_channel = scp.upload(local_path, remote_path)
137
+ upload_channel.wait
138
+ ensure
139
+ if upload_channel
140
+ upload_channel.close
141
+ upload_channel.wait
142
+ end
143
+ end
144
+
145
+ def ensure_remote_directory(path)
146
+ exit_code, output = run("mkdir -p #{path}")
147
+ if exit_code != 0
148
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{output}"
149
+ end
150
+ end
151
+
152
+ def inactive?
153
+ @session.nil? || @session.channels.empty?
154
+ end
155
+
156
+ def close
157
+ @logger.debug("closing session to #{@user}@#{@host}")
158
+ @session.close unless @session.nil? || @session.closed?
159
+ end
160
+
161
+ private
162
+
163
+ def session
164
+ @session ||= begin
165
+ @logger.debug("opening session to #{@user}@#{@host}")
166
+ Net::SSH.start(@host, @user, ssh_options)
167
+ end
168
+ end
169
+
170
+ def ssh_options
171
+ ssh_options = {}
172
+ ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
173
+ ssh_options[:user_known_hosts_file] = @known_hosts_file if @known_hosts_file
174
+ ssh_options[:keys_only] = true
175
+ # if the host public key is contained in the known_hosts_file,
176
+ # verify it, otherwise, if missing, import it and continue
177
+ ssh_options[:paranoid] = true
178
+ ssh_options[:auth_methods] = ["publickey"]
179
+ return ssh_options
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,235 @@
1
+ require 'smart_proxy_remote_execution_ssh/connector'
2
+
3
+ module Proxy::RemoteExecution::Ssh
4
+ # Service that handles running external commands for Actions::Command
5
+ # Dynflow action. It runs just one (actor) thread for all the commands
6
+ # running in the system and updates the Dynflow actions periodically.
7
+ class Dispatcher < ::Dynflow::Actor
8
+ # command comming from action
9
+ class Command
10
+ attr_reader :id, :host, :ssh_user, :effective_user, :script, :host_public_key, :suspended_action
11
+
12
+ def initialize(data)
13
+ validate!(data)
14
+
15
+ @id = data[:id]
16
+ @host = data[:host]
17
+ @ssh_user = data[:ssh_user]
18
+ @effective_user = data[:effective_user]
19
+ @script = data[:script]
20
+ @host_public_key = data[:host_public_key]
21
+ @suspended_action = data[:suspended_action]
22
+ end
23
+
24
+ def validate!(data)
25
+ required_fields = [:id, :host, :ssh_user, :script, :suspended_action]
26
+ missing_fields = required_fields.find_all { |f| !data[f] }
27
+ raise ArgumentError, "Missing fields: #{missing_fields}" unless missing_fields.empty?
28
+ end
29
+ end
30
+
31
+ # update sent back to the suspended action
32
+ class CommandUpdate
33
+ attr_reader :buffer, :exit_status
34
+
35
+ def initialize(buffer, exit_status)
36
+ @buffer = buffer
37
+ @exit_status = exit_status
38
+ end
39
+
40
+ def buffer_to_hash
41
+ buffer.map do |buffer_data|
42
+ { :output_type => buffer_data.data_type,
43
+ :output => buffer_data.data,
44
+ :timestamp => buffer_data.timestamp.to_f }
45
+ end
46
+ end
47
+ end
48
+
49
+ def initialize(options = {})
50
+ @clock = options[:clock] || Dynflow::Clock.spawn('proxy-dispatcher-clock')
51
+ @logger = options[:logger] || Logger.new($stderr)
52
+ @connector_class = options[:connector_class] || Connector
53
+ @local_working_dir = options[:local_working_dir] || '/tmp/foreman-proxy-ssh/server'
54
+ @remote_working_dir = options[:remote_working_dir] || '/tmp/foreman-proxy-ssh/client'
55
+ @refresh_interval = options[:refresh_interval] || 1
56
+ @client_private_key_file = Proxy::RemoteExecution::Ssh.private_key_file
57
+
58
+ @connectors = {}
59
+ @command_buffer = Hash.new { |h, k| h[k] = [] }
60
+ @refresh_planned = false
61
+ end
62
+
63
+ def initialize_command(command)
64
+ @logger.debug("initalizing command [#{command}]")
65
+ connector = self.connector_for_command(command)
66
+ remote_script = cp_script_to_remote(connector, command)
67
+ if command.effective_user && command.effective_user != command.ssh_user
68
+ su_prefix = "su - #{command.effective_user} -c "
69
+ end
70
+ output_path = File.join(File.dirname(remote_script), 'output')
71
+
72
+ connector.async_run("#{su_prefix}#{remote_script} | /usr/bin/tee #{output_path}") do |data|
73
+ command_buffer(command) << data
74
+ end
75
+ rescue => e
76
+ @logger.error("error while initalizing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
77
+ command_buffer(command).concat([Connector::DebugData.new("Exception: #{e.class} #{e.message}"),
78
+ Connector::StatusData.new('INIT_ERROR')])
79
+ ensure
80
+ plan_next_refresh
81
+ end
82
+
83
+ def refresh
84
+ finished_commands = []
85
+ refresh_connectors
86
+
87
+ @command_buffer.each do |command, buffer|
88
+ unless buffer.empty?
89
+ status = refresh_command_buffer(command, buffer)
90
+ if status
91
+ finished_commands << command
92
+ end
93
+ end
94
+ end
95
+
96
+ finished_commands.each { |command| finish_command(command) }
97
+ close_inactive_connectors
98
+ ensure
99
+ @refresh_planned = false
100
+ plan_next_refresh
101
+ end
102
+
103
+ def refresh_command_buffer(command, buffer)
104
+ status = nil
105
+ @logger.debug("command #{command} got new output: #{buffer.inspect}")
106
+ buffer.delete_if do |data|
107
+ if data.is_a? Connector::StatusData
108
+ status = data.data
109
+ true
110
+ end
111
+ end
112
+ command.suspended_action << CommandUpdate.new(buffer, status)
113
+ clear_command(command)
114
+ if status
115
+ @logger.debug("command [#{command}] finished with status #{status}")
116
+ return status
117
+ end
118
+ end
119
+
120
+ def kill(command)
121
+ @logger.debug("killing command [#{command}]")
122
+ connector_for_command(command).run("pkill -f #{remote_command_file(command, 'script')}")
123
+ end
124
+
125
+ protected
126
+
127
+ def connector_for_command(command, only_if_exists = false)
128
+ if connector = @connectors[[command.host, command.ssh_user]]
129
+ return connector
130
+ end
131
+ return nil if only_if_exists
132
+ @connectors[[command.host, command.ssh_user]] = open_connector(command)
133
+ end
134
+
135
+ def local_command_dir(command)
136
+ File.join(@local_working_dir, command.id)
137
+ end
138
+
139
+ def local_command_file(command, filename)
140
+ File.join(local_command_dir(command), filename)
141
+ end
142
+
143
+ def remote_command_dir(command)
144
+ File.join(@remote_working_dir, command.id)
145
+ end
146
+
147
+ def remote_command_file(command, filename)
148
+ File.join(remote_command_dir(command), filename)
149
+ end
150
+
151
+ def ensure_local_directory(path)
152
+ if File.exist?(path)
153
+ raise "#{path} expected to be a directory" unless File.directory?(path)
154
+ else
155
+ FileUtils.mkdir_p(path)
156
+ end
157
+ return path
158
+ end
159
+
160
+ def cp_script_to_remote(connector, command)
161
+ local_script_file = write_command_file_locally(command, 'script', command.script)
162
+ File.chmod(0777, local_script_file)
163
+ remote_script_file = remote_command_file(command, 'script')
164
+ connector.upload_file(local_script_file, remote_script_file)
165
+ return remote_script_file
166
+ end
167
+
168
+ def write_command_file_locally(command, filename, content)
169
+ path = local_command_file(command, filename)
170
+ ensure_local_directory(File.dirname(path))
171
+ File.write(path, content)
172
+ return path
173
+ end
174
+
175
+ def open_connector(command)
176
+ options = { :logger => @logger }
177
+ options[:known_hosts_file] = prepare_known_hosts(command)
178
+ options[:client_private_key_file] = @client_private_key_file
179
+ @connector_class.new(command.host, command.ssh_user, options)
180
+ end
181
+
182
+ def prepare_known_hosts(command)
183
+ path = local_command_file(command, 'known_hosts')
184
+ if command.host_public_key
185
+ write_command_file_locally(command, 'known_hosts', "#{command.host} #{command.host_public_key}")
186
+ end
187
+ return path
188
+ end
189
+
190
+ def close_inactive_connectors
191
+ @connectors.delete_if do |_, connector|
192
+ if connector.inactive?
193
+ connector.close
194
+ true
195
+ end
196
+ end
197
+ end
198
+
199
+ def refresh_connectors
200
+ @logger.debug("refreshing #{@connectors.size} connectors")
201
+
202
+ @connectors.values.each do |connector|
203
+ begin
204
+ connector.refresh
205
+ rescue => e
206
+ @command_buffer.each do |command, buffer|
207
+ if connector_for_command(command, false)
208
+ buffer << Connector::DebugData.new("Exception: #{e.class} #{e.message}")
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ def command_buffer(command)
216
+ @command_buffer[command]
217
+ end
218
+
219
+ def clear_command(command)
220
+ @command_buffer[command] = []
221
+ end
222
+
223
+ def finish_command(command)
224
+ @command_buffer.delete(command)
225
+ end
226
+
227
+ def plan_next_refresh
228
+ if @connectors.any? && !@refresh_planned
229
+ @logger.debug("planning to refresh")
230
+ @clock.ping(reference, Time.now + @refresh_interval, :refresh)
231
+ @refresh_planned = true
232
+ end
233
+ end
234
+ end
235
+ end