smart_proxy_remote_execution_ssh_core 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,57 @@
1
+ [![Build Status](https://img.shields.io/jenkins/s/http/ci.theforeman.org/test_plugin_smart_proxy_remote_execution_ssh_master.svg)](http://ci.theforeman.org/job/test_plugin_smart_proxy_remote_execution_ssh_master)
2
+ [![Gem Version](https://img.shields.io/gem/v/smart_proxy_remote_execution_ssh.svg)](https://rubygems.org/gems/smart_proxy_remote_execution_ssh)
3
+ [![Code Climate](https://codeclimate.com/github/theforeman/smart_proxy_remote_execution_ssh/badges/gpa.svg)](https://codeclimate.com/github/theforeman/smart_proxy_remote_execution_ssh)
4
+ [![GPL License](https://img.shields.io/github/license/theforeman/smart_proxy_remote_execution_ssh.svg)](https://github.com/theforeman/smart_proxy_remote_execution_ssh/blob/master/LICENSE)
5
+
6
+ # Smart-proxy Ssh plugin
7
+
8
+ This a plugin for foreman smart-proxy allowing using ssh for the
9
+ [remote execution](http://theforeman.github.io/foreman_remote_execution/)
10
+
11
+ ## Installation
12
+
13
+ Add this line to your smart proxy bundler.d/ssh.rb gemfile:
14
+
15
+ ```ruby
16
+ gem 'smart_proxy_dynflow', :git => 'https://github.com/iNecas/smart_proxy_dynflow.git'
17
+ gem 'smart_proxy_ssh', :git => 'https://github.com/iNecas/smart_proxy_ssh.git'
18
+ ```
19
+
20
+ Enable the plugins in your smart proxy:
21
+
22
+ ```bash
23
+ cat > config/settings.d/dynflow.yml <<EOF
24
+ ---
25
+ :enabled: true
26
+ EOF
27
+
28
+ cat > config/settings.d/remote_execution_ssh.yml <<EOF
29
+ ---
30
+ :enabled: true
31
+ EOF
32
+ ```
33
+
34
+ Install the dependencies
35
+
36
+ $ bundle
37
+
38
+ ## Usage
39
+
40
+ To configure this plugin you can use template from settings.d/remote_execution_ssh.yml.example.
41
+ You must place remote_execution_ssh.yml config file (based on this template) to your
42
+ smart-proxy config/settings.d/ directory.
43
+
44
+ Also, you need to have the `dynflow` plugin enabled to be able to
45
+ trigger the tasks.
46
+
47
+ The simplest thing one can do is just to trigger a command:
48
+
49
+ ```
50
+ curl http://my-proxy.example.com:9292/dynflow/tasks \
51
+ -X POST -H 'Content-Type: application/json'\
52
+ -d '{"action_name": "Proxy::RemoteExecution::Ssh::CommandAction",
53
+ "action_input": {"task_id" : "1234'$RANDOM'",
54
+ "script": "/usr/bin/ls",
55
+ "hostname": "localhost",
56
+ "effective_user": "root"}}'
57
+ ```
@@ -0,0 +1 @@
1
+ gem 'pry'
@@ -0,0 +1 @@
1
+ gem 'smart_proxy_remote_execution_ssh'
@@ -0,0 +1,49 @@
1
+ require 'smart_proxy_remote_execution_ssh_core/settings'
2
+ require 'smart_proxy_remote_execution_ssh_core/version'
3
+ require 'smart_proxy_dynflow_core'
4
+ require 'smart_proxy_remote_execution_ssh_core/command_action'
5
+ require 'smart_proxy_remote_execution_ssh_core/command_update'
6
+ require 'smart_proxy_remote_execution_ssh_core/connector'
7
+ require 'smart_proxy_remote_execution_ssh_core/dispatcher'
8
+ require 'smart_proxy_remote_execution_ssh_core/session'
9
+
10
+ module Proxy
11
+ module RemoteExecution
12
+ module Ssh
13
+ class << self
14
+ def initialize
15
+ unless private_key_file
16
+ raise "settings for `ssh_identity_key` not set"
17
+ end
18
+
19
+ unless File.exist?(private_key_file)
20
+ raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
21
+ "You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
22
+ end
23
+
24
+ unless File.exist?(public_key_file)
25
+ raise "Ssh public key file #{public_key_file} doesn't exist"
26
+ end
27
+
28
+ @dispatcher = Proxy::RemoteExecution::Ssh::Dispatcher.spawn('proxy-ssh-dispatcher',
29
+ :clock => SmartProxyDynflowCore::Core.instance.world.clock,
30
+ :logger => SmartProxyDynflowCore::Core.instance.world.logger)
31
+ end
32
+
33
+ def dispatcher
34
+ @dispatcher || initialize
35
+ end
36
+
37
+ def private_key_file
38
+ File.expand_path(Settings.instance.ssh_identity_key_file)
39
+ end
40
+
41
+ def public_key_file
42
+ File.expand_path("#{private_key_file}.pub")
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ SmartProxyDynflowCore::Core.after_initialize { Proxy::RemoteExecution::Ssh.initialize }
@@ -0,0 +1,86 @@
1
+ module Proxy
2
+ module RemoteExecution
3
+ module Ssh
4
+ class CommandAction < ::Dynflow::Action
5
+ include Dynflow::Action::Cancellable
6
+ include ::SmartProxyDynflowCore::Callback::PlanHelper
7
+
8
+ def plan(input)
9
+ if callback = input['callback']
10
+ input[:task_id] = callback['task_id']
11
+ else
12
+ input[:task_id] ||= SecureRandom.uuid
13
+ end
14
+ plan_with_callback(input)
15
+ end
16
+
17
+ def run(event = nil)
18
+ case event
19
+ when nil
20
+ init_run
21
+ when CommandUpdate
22
+ process_update(event)
23
+ when Dynflow::Action::Cancellable::Cancel
24
+ kill_run
25
+ when Dynflow::Action::Skip
26
+ # do nothing
27
+ else
28
+ raise "Unexpected event #{event.inspect}"
29
+ end
30
+ rescue => e
31
+ action_logger.error(e)
32
+ process_update(CommandUpdate.new(CommandUpdate.encode_exception("Proxy error", e)))
33
+ end
34
+
35
+ def finalize
36
+ # To mark the task as a whole as failed
37
+ error! "Script execution failed" if failed_run?
38
+ end
39
+
40
+ def rescue_strategy_for_self
41
+ Dynflow::Action::Rescue::Skip
42
+ end
43
+
44
+ def command
45
+ @command ||= Dispatcher::Command.new(:id => input[:task_id],
46
+ :host => input[:hostname],
47
+ :ssh_user => input[:ssh_user] || 'root',
48
+ :effective_user => input[:effective_user],
49
+ :script => input[:script],
50
+ :effective_user_method => input[:effective_user_method],
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 process_update(update)
72
+ output[:result].concat(update.buffer_to_hash)
73
+ if update.exit_status
74
+ finish_run(update)
75
+ else
76
+ suspend
77
+ end
78
+ end
79
+
80
+ def failed_run?
81
+ output[:exit_status] != 0
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,78 @@
1
+ module Proxy
2
+ module RemoteExecution
3
+ module Ssh
4
+ # update sent back to the suspended action
5
+ class CommandUpdate
6
+ attr_reader :buffer, :exit_status
7
+
8
+ def initialize(buffer)
9
+ @buffer = buffer
10
+ extract_exit_status
11
+ end
12
+
13
+ def extract_exit_status
14
+ @buffer.delete_if do |data|
15
+ if data.is_a? StatusData
16
+ @exit_status = data.data
17
+ true
18
+ end
19
+ end
20
+ end
21
+
22
+ def buffer_to_hash
23
+ buffer.map(&:to_hash)
24
+ end
25
+
26
+ def self.encode_exception(description, exception, fatal = true)
27
+ ret = [DebugData.new("#{description}\n#{exception.class} #{exception.message}")]
28
+ ret << StatusData.new('EXCEPTION') if fatal
29
+ return ret
30
+ end
31
+
32
+ class Data
33
+ attr_reader :data, :timestamp
34
+
35
+ def initialize(data, timestamp = Time.now)
36
+ @data = data
37
+ @data = @data.force_encoding('UTF-8') if @data.is_a? String
38
+ @timestamp = timestamp
39
+ end
40
+
41
+ def data_type
42
+ raise NotImplemented
43
+ end
44
+
45
+ def to_hash
46
+ { :output_type => data_type,
47
+ :output => data,
48
+ :timestamp => timestamp.to_f }
49
+ end
50
+ end
51
+
52
+ class StdoutData < Data
53
+ def data_type
54
+ :stdout
55
+ end
56
+ end
57
+
58
+ class StderrData < Data
59
+ def data_type
60
+ :stderr
61
+ end
62
+ end
63
+
64
+ class DebugData < Data
65
+ def data_type
66
+ :debug
67
+ end
68
+ end
69
+
70
+ class StatusData < Data
71
+ def data_type
72
+ :status
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,151 @@
1
+ require 'net/ssh'
2
+ require 'net/scp'
3
+
4
+ module Proxy
5
+ module RemoteExecution
6
+ module Ssh
7
+ # Service that handles running external commands for Actions::Command
8
+ # Dynflow action. It runs just one (actor) thread for all the commands
9
+ # running in the system and updates the Dynflow actions periodically.
10
+ class Connector
11
+ MAX_PROCESS_RETRIES = 3
12
+
13
+ def initialize(host, user, options = {})
14
+ @host = host
15
+ @user = user
16
+ @logger = options[:logger] || Logger.new($stderr)
17
+ @client_private_key_file = options[:client_private_key_file]
18
+ @known_hosts_file = options[:known_hosts_file]
19
+ end
20
+
21
+ # Initiates run of the remote command and yields the data when
22
+ # available. The yielding doesn't happen automatically, but as
23
+ # part of calling the `refresh` method.
24
+ def async_run(command)
25
+ started = false
26
+ session.open_channel do |channel|
27
+ channel.request_pty
28
+
29
+ channel.on_data { |ch, data| yield CommandUpdate::StdoutData.new(data) }
30
+
31
+ channel.on_extended_data { |ch, type, data| yield CommandUpdate::StderrData.new(data) }
32
+
33
+ # standard exit of the command
34
+ channel.on_request("exit-status") { |ch, data| yield CommandUpdate::StatusData.new(data.read_long) }
35
+
36
+ # on signal: sedning the signal value (such as 'TERM')
37
+ channel.on_request("exit-signal") do |ch, data|
38
+ yield(CommandUpdate::StatusData.new(data.read_string))
39
+ ch.close
40
+ # wait for the channel to finish so that we know at the end
41
+ # that the session is inactive
42
+ ch.wait
43
+ end
44
+
45
+ channel.exec(command) do |ch, success|
46
+ started = true
47
+ unless success
48
+ CommandUpdate.encode_exception("Error initializing command #{command}", e).each do |data|
49
+ yield data
50
+ end
51
+ end
52
+ end
53
+ end
54
+ session.process(0) until started
55
+ return true
56
+ end
57
+
58
+ def run(command)
59
+ output = ""
60
+ exit_status = nil
61
+ channel = session.open_channel do |ch|
62
+ ch.on_data { |data| output.concat(data) }
63
+
64
+ ch.on_extended_data { |_, _, data| output.concat(data) }
65
+
66
+ ch.on_request("exit-status") { |_, data| exit_status = data.read_long }
67
+
68
+ # on signal: sedning the signal value (such as 'TERM')
69
+ ch.on_request("exit-signal") do |_, data|
70
+ exit_status = data.read_string
71
+ ch.close
72
+ ch.wait
73
+ end
74
+
75
+ ch.exec command do |_, success|
76
+ raise "could not execute command" unless success
77
+ end
78
+ end
79
+ channel.wait
80
+ return exit_status, output
81
+ end
82
+
83
+ # calls the callback registered in the `async_run` when some data
84
+ # for the session are available
85
+ def refresh
86
+ return if @session.nil?
87
+ tries = 0
88
+ begin
89
+ session.process(0)
90
+ rescue Net::SSH::Disconnect => e
91
+ session.shutdown!
92
+ raise e
93
+ rescue => e
94
+ @logger.error("Error while processing ssh channel: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
95
+ tries += 1
96
+ if tries <= MAX_PROCESS_RETRIES
97
+ retry
98
+ else
99
+ raise e
100
+ end
101
+ end
102
+ end
103
+
104
+ def upload_file(local_path, remote_path)
105
+ ensure_remote_directory(File.dirname(remote_path))
106
+ scp = Net::SCP.new(session)
107
+ upload_channel = scp.upload(local_path, remote_path)
108
+ upload_channel.wait
109
+ ensure
110
+ if upload_channel
111
+ upload_channel.close
112
+ upload_channel.wait
113
+ end
114
+ end
115
+
116
+ def ensure_remote_directory(path)
117
+ exit_code, output = run("mkdir -p #{path}")
118
+ if exit_code != 0
119
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{output}"
120
+ end
121
+ end
122
+
123
+ def close
124
+ @logger.debug("closing session to #{@user}@#{@host}")
125
+ @session.close unless @session.nil? || @session.closed?
126
+ end
127
+
128
+ private
129
+
130
+ def session
131
+ @session ||= begin
132
+ @logger.debug("opening session to #{@user}@#{@host}")
133
+ Net::SSH.start(@host, @user, ssh_options)
134
+ end
135
+ end
136
+
137
+ def ssh_options
138
+ ssh_options = {}
139
+ ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
140
+ ssh_options[:user_known_hosts_file] = @known_hosts_file if @known_hosts_file
141
+ ssh_options[:keys_only] = true
142
+ # if the host public key is contained in the known_hosts_file,
143
+ # verify it, otherwise, if missing, import it and continue
144
+ ssh_options[:paranoid] = true
145
+ ssh_options[:auth_methods] = ["publickey"]
146
+ return ssh_options
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,97 @@
1
+ require 'smart_proxy_remote_execution_ssh_core/session'
2
+
3
+ module Proxy
4
+ module RemoteExecution
5
+ module Ssh
6
+ # Service that handles running external commands for Actions::Command
7
+ # Dynflow action. It runs just one (actor) thread for all the commands
8
+ # running in the system and updates the Dynflow actions periodically.
9
+ class Dispatcher < ::Dynflow::Actor
10
+ # command comming from action
11
+ class Command
12
+ attr_reader :id, :host, :ssh_user, :effective_user, :effective_user_method, :script, :host_public_key, :suspended_action
13
+
14
+ def initialize(data)
15
+ validate!(data)
16
+
17
+ @id = data[:id]
18
+ @host = data[:host]
19
+ @ssh_user = data[:ssh_user]
20
+ @effective_user = data[:effective_user]
21
+ @effective_user_method = data[:effective_user_method] || 'su'
22
+ @script = data[:script]
23
+ @host_public_key = data[:host_public_key]
24
+ @suspended_action = data[:suspended_action]
25
+ end
26
+
27
+ def validate!(data)
28
+ required_fields = [:id, :host, :ssh_user, :script, :suspended_action]
29
+ missing_fields = required_fields.find_all { |f| !data[f] }
30
+ raise ArgumentError, "Missing fields: #{missing_fields}" unless missing_fields.empty?
31
+ end
32
+ end
33
+
34
+ def initialize(options = {})
35
+ @clock = options[:clock] || Dynflow::Clock.spawn('proxy-dispatcher-clock')
36
+ @logger = options[:logger] || Logger.new($stderr)
37
+
38
+ @session_args = { :logger => @logger,
39
+ :clock => @clock,
40
+ :connector_class => options[:connector_class] || Connector,
41
+ :local_working_dir => options[:local_working_dir] || Settings.instance.local_working_dir,
42
+ :remote_working_dir => options[:remote_working_dir] || Settings.instance.remote_working_dir,
43
+ :client_private_key_file => Settings.instance.ssh_identity_key_file,
44
+ :refresh_interval => options[:refresh_interval] || 1 }
45
+
46
+ @sessions = {}
47
+ end
48
+
49
+ def initialize_command(command)
50
+ @logger.debug("initalizing command [#{command}]")
51
+ open_session(command)
52
+ rescue => exception
53
+ handle_command_exception(command, exception)
54
+ end
55
+
56
+ def kill(command)
57
+ @logger.debug("killing command [#{command}]")
58
+ session = @sessions[command.id]
59
+ session.tell(:kill) if session
60
+ rescue => exception
61
+ handle_command_exception(command, exception, false)
62
+ end
63
+
64
+ def finish_command(command)
65
+ close_session(command)
66
+ rescue => exception
67
+ handle_command_exception(command, exception)
68
+ end
69
+
70
+ private
71
+
72
+ def handle_command_exception(command, exception, fatal = true)
73
+ @logger.error("error while dispatching command #{command} to session:"\
74
+ "#{exception.class} #{exception.message}:\n #{exception.backtrace.join("\n")}")
75
+ command_data = CommandUpdate.encode_exception("Failed to dispatch the command", exception, fatal)
76
+ command.suspended_action << CommandUpdate.new(command_data)
77
+ close_session(command) if fatal
78
+ end
79
+
80
+ def open_session(command)
81
+ raise "Session already opened for command #{command}" if @sessions[command.id]
82
+ options = { :name => "proxy-ssh-session-#{command.host}-#{command.ssh_user}-#{command.id}",
83
+ :args => [@session_args.merge(:command => command)],
84
+ :supervise => true }
85
+ @sessions[command.id] = Proxy::RemoteExecution::Ssh::Session.spawn(options)
86
+ end
87
+
88
+ def close_session(command)
89
+ session = @sessions.delete(command.id)
90
+ return unless session
91
+ @logger.debug("closing session for command [#{command}], #{@sessions.size} session(s) left ")
92
+ session.tell([:start_termination, Concurrent.future])
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end