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.
- checksums.yaml +15 -0
- data/Gemfile +10 -0
- data/LICENSE +674 -0
- data/bundler.d/Gemfile.local.rb +1 -0
- data/bundler.d/remote_execution_ssh.rb +1 -0
- data/lib/smart_proxy_remote_execution_ssh.rb +44 -0
- data/lib/smart_proxy_remote_execution_ssh/api.rb +9 -0
- data/lib/smart_proxy_remote_execution_ssh/command_action.rb +75 -0
- data/lib/smart_proxy_remote_execution_ssh/connector.rb +182 -0
- data/lib/smart_proxy_remote_execution_ssh/dispatcher.rb +235 -0
- data/lib/smart_proxy_remote_execution_ssh/http_config.ru +4 -0
- data/lib/smart_proxy_remote_execution_ssh/plugin.rb +15 -0
- data/lib/smart_proxy_remote_execution_ssh/version.rb +7 -0
- data/settings.d/ssh.yml.example +4 -0
- metadata +200 -0
@@ -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,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
|