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 +4 -4
- data/{bundler.plugins.d → bundler.d}/remote_execution_ssh.rb +0 -0
- data/lib/smart_proxy_remote_execution_ssh/actions/pull_script.rb +110 -0
- data/lib/smart_proxy_remote_execution_ssh/actions/run_script.rb +16 -2
- data/lib/smart_proxy_remote_execution_ssh/actions.rb +6 -0
- data/lib/smart_proxy_remote_execution_ssh/api.rb +49 -0
- data/lib/smart_proxy_remote_execution_ssh/cockpit.rb +87 -71
- data/lib/smart_proxy_remote_execution_ssh/dispatcher.rb +2 -2
- data/lib/smart_proxy_remote_execution_ssh/job_storage.rb +51 -0
- data/lib/smart_proxy_remote_execution_ssh/net_ssh_compat.rb +228 -0
- data/lib/smart_proxy_remote_execution_ssh/plugin.rb +13 -7
- data/lib/smart_proxy_remote_execution_ssh/runners/fake_script_runner.rb +1 -1
- data/lib/smart_proxy_remote_execution_ssh/runners/polling_script_runner.rb +5 -6
- data/lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb +86 -133
- data/lib/smart_proxy_remote_execution_ssh/utils.rb +24 -0
- data/lib/smart_proxy_remote_execution_ssh/version.rb +1 -1
- data/lib/smart_proxy_remote_execution_ssh.rb +37 -6
- data/settings.d/remote_execution_ssh.yml.example +10 -4
- metadata +19 -14
@@ -0,0 +1,228 @@
|
|
1
|
+
module Proxy::RemoteExecution
|
2
|
+
module NetSSHCompat
|
3
|
+
class Buffer
|
4
|
+
# exposes the raw content of the buffer
|
5
|
+
attr_reader :content
|
6
|
+
|
7
|
+
# the current position of the pointer in the buffer
|
8
|
+
attr_accessor :position
|
9
|
+
|
10
|
+
# Creates a new buffer, initialized to the given content. The position
|
11
|
+
# is initialized to the beginning of the buffer.
|
12
|
+
def initialize(content = +'')
|
13
|
+
@content = content.to_s
|
14
|
+
@position = 0
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns the length of the buffer's content.
|
18
|
+
def length
|
19
|
+
@content.length
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns the number of bytes available to be read (e.g., how many bytes
|
23
|
+
# remain between the current position and the end of the buffer).
|
24
|
+
def available
|
25
|
+
length - position
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns a copy of the buffer's content.
|
29
|
+
def to_s
|
30
|
+
(@content || "").dup
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns +true+ if the buffer contains no data (e.g., it is of zero length).
|
34
|
+
def empty?
|
35
|
+
@content.empty?
|
36
|
+
end
|
37
|
+
|
38
|
+
# Resets the pointer to the start of the buffer. Subsequent reads will
|
39
|
+
# begin at position 0.
|
40
|
+
def reset!
|
41
|
+
@position = 0
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns true if the pointer is at the end of the buffer. Subsequent
|
45
|
+
# reads will return nil, in this case.
|
46
|
+
def eof?
|
47
|
+
@position >= length
|
48
|
+
end
|
49
|
+
|
50
|
+
# Resets the buffer, making it empty. Also, resets the read position to
|
51
|
+
# 0.
|
52
|
+
def clear!
|
53
|
+
@content = +''
|
54
|
+
@position = 0
|
55
|
+
end
|
56
|
+
|
57
|
+
# Consumes n bytes from the buffer, where n is the current position
|
58
|
+
# unless otherwise specified. This is useful for removing data from the
|
59
|
+
# buffer that has previously been read, when you are expecting more data
|
60
|
+
# to be appended. It helps to keep the size of buffers down when they
|
61
|
+
# would otherwise tend to grow without bound.
|
62
|
+
#
|
63
|
+
# Returns the buffer object itself.
|
64
|
+
def consume!(count = position)
|
65
|
+
if count >= length
|
66
|
+
# OPTIMIZE: a fairly common case
|
67
|
+
clear!
|
68
|
+
elsif count.positive?
|
69
|
+
@content = @content[count..-1] || +''
|
70
|
+
@position -= count
|
71
|
+
@position = 0 if @position.negative?
|
72
|
+
end
|
73
|
+
self
|
74
|
+
end
|
75
|
+
|
76
|
+
# Appends the given text to the end of the buffer. Does not alter the
|
77
|
+
# read position. Returns the buffer object itself.
|
78
|
+
def append(text)
|
79
|
+
@content << text
|
80
|
+
self
|
81
|
+
end
|
82
|
+
|
83
|
+
# Reads and returns the next +count+ bytes from the buffer, starting from
|
84
|
+
# the read position. If +count+ is +nil+, this will return all remaining
|
85
|
+
# text in the buffer. This method will increment the pointer.
|
86
|
+
def read(count = nil)
|
87
|
+
count ||= length
|
88
|
+
count = length - @position if @position + count > length
|
89
|
+
@position += count
|
90
|
+
@content[@position - count, count]
|
91
|
+
end
|
92
|
+
|
93
|
+
# Writes the given data literally into the string. Does not alter the
|
94
|
+
# read position. Returns the buffer object.
|
95
|
+
def write(*data)
|
96
|
+
data.each { |datum| @content << datum.dup.force_encoding('BINARY') }
|
97
|
+
self
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
module BufferedIO
|
102
|
+
# This module is used to extend sockets and other IO objects, to allow
|
103
|
+
# them to be buffered for both read and write. This abstraction makes it
|
104
|
+
# quite easy to write a select-based event loop
|
105
|
+
# (see Net::SSH::Connection::Session#listen_to).
|
106
|
+
#
|
107
|
+
# The general idea is that instead of calling #read directly on an IO that
|
108
|
+
# has been extended with this module, you call #fill (to add pending input
|
109
|
+
# to the internal read buffer), and then #read_available (to read from that
|
110
|
+
# buffer). Likewise, you don't call #write directly, you call #enqueue to
|
111
|
+
# add data to the write buffer, and then #send_pending or #wait_for_pending_sends
|
112
|
+
# to actually send the data across the wire.
|
113
|
+
#
|
114
|
+
# In this way you can easily use the object as an argument to IO.select,
|
115
|
+
# calling #fill when it is available for read, or #send_pending when it is
|
116
|
+
# available for write, and then call #enqueue and #read_available during
|
117
|
+
# the idle times.
|
118
|
+
#
|
119
|
+
# socket = TCPSocket.new(address, port)
|
120
|
+
# socket.extend(Net::SSH::BufferedIo)
|
121
|
+
#
|
122
|
+
# ssh.listen_to(socket)
|
123
|
+
#
|
124
|
+
# ssh.loop do
|
125
|
+
# if socket.available > 0
|
126
|
+
# puts socket.read_available
|
127
|
+
# socket.enqueue("response\n")
|
128
|
+
# end
|
129
|
+
# end
|
130
|
+
#
|
131
|
+
# Note that this module must be used to extend an instance, and should not
|
132
|
+
# be included in a class. If you do want to use it via an include, then you
|
133
|
+
# must make sure to invoke the private #initialize_buffered_io method in
|
134
|
+
# your class' #initialize method:
|
135
|
+
#
|
136
|
+
# class Foo < IO
|
137
|
+
# include Net::SSH::BufferedIo
|
138
|
+
#
|
139
|
+
# def initialize
|
140
|
+
# initialize_buffered_io
|
141
|
+
# # ...
|
142
|
+
# end
|
143
|
+
# end
|
144
|
+
|
145
|
+
# Tries to read up to +n+ bytes of data from the remote end, and appends
|
146
|
+
# the data to the input buffer. It returns the number of bytes read, or 0
|
147
|
+
# if no data was available to be read.
|
148
|
+
def fill(count = 8192)
|
149
|
+
input.consume!
|
150
|
+
data = recv(count)
|
151
|
+
input.append(data)
|
152
|
+
return data.length
|
153
|
+
rescue EOFError => e
|
154
|
+
@input_errors << e
|
155
|
+
return 0
|
156
|
+
end
|
157
|
+
|
158
|
+
# Read up to +length+ bytes from the input buffer. If +length+ is nil,
|
159
|
+
# all available data is read from the buffer. (See #available.)
|
160
|
+
def read_available(length = nil)
|
161
|
+
input.read(length || available)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns the number of bytes available to be read from the input buffer.
|
165
|
+
# (See #read_available.)
|
166
|
+
def available
|
167
|
+
input.available
|
168
|
+
end
|
169
|
+
|
170
|
+
# Enqueues data in the output buffer, to be written when #send_pending
|
171
|
+
# is called. Note that the data is _not_ sent immediately by this method!
|
172
|
+
def enqueue(data)
|
173
|
+
output.append(data)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Sends as much of the pending output as possible. Returns +true+ if any
|
177
|
+
# data was sent, and +false+ otherwise.
|
178
|
+
def send_pending
|
179
|
+
if output.length.positive?
|
180
|
+
sent = send(output.to_s, 0)
|
181
|
+
output.consume!(sent)
|
182
|
+
return sent.positive?
|
183
|
+
else
|
184
|
+
return false
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Calls #send_pending repeatedly, if necessary, blocking until the output
|
189
|
+
# buffer is empty.
|
190
|
+
def wait_for_pending_sends
|
191
|
+
send_pending
|
192
|
+
while output.length.positive?
|
193
|
+
result = IO.select(nil, [self]) || next
|
194
|
+
next unless result[1].any?
|
195
|
+
|
196
|
+
send_pending
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
private
|
201
|
+
|
202
|
+
#--
|
203
|
+
# Can't use attr_reader here (after +private+) without incurring the
|
204
|
+
# wrath of "ruby -w". We hates it.
|
205
|
+
#++
|
206
|
+
|
207
|
+
def input
|
208
|
+
@input
|
209
|
+
end
|
210
|
+
|
211
|
+
def output
|
212
|
+
@output
|
213
|
+
end
|
214
|
+
|
215
|
+
# Initializes the intput and output buffers for this object. This method
|
216
|
+
# is called automatically when the module is mixed into an object via
|
217
|
+
# Object#extend (see Net::SSH::BufferedIo.extended), but must be called
|
218
|
+
# explicitly in the +initialize+ method of any class that uses
|
219
|
+
# Module#include to add this module.
|
220
|
+
def initialize_buffered_io
|
221
|
+
@input = Buffer.new
|
222
|
+
@input_errors = []
|
223
|
+
@output = Buffer.new
|
224
|
+
@output_errors = []
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
module Proxy::RemoteExecution::Ssh
|
2
2
|
class Plugin < Proxy::Plugin
|
3
|
-
SSH_LOG_LEVELS = %w[debug info
|
3
|
+
SSH_LOG_LEVELS = %w[debug info error fatal].freeze
|
4
|
+
MODES = %i[ssh async-ssh pull pull-mqtt].freeze
|
4
5
|
|
5
6
|
http_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
|
6
7
|
https_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
|
@@ -11,11 +12,13 @@ module Proxy::RemoteExecution::Ssh
|
|
11
12
|
:remote_working_dir => '/var/tmp',
|
12
13
|
:local_working_dir => '/var/tmp',
|
13
14
|
:kerberos_auth => false,
|
14
|
-
:async_ssh => false,
|
15
15
|
# When set to nil, makes REX use the runner's default interval
|
16
16
|
# :runner_refresh_interval => nil,
|
17
|
-
:ssh_log_level => :
|
18
|
-
:cleanup_working_dirs => true
|
17
|
+
:ssh_log_level => :error,
|
18
|
+
:cleanup_working_dirs => true,
|
19
|
+
# :mqtt_broker => nil,
|
20
|
+
# :mqtt_port => nil,
|
21
|
+
:mode => :ssh
|
19
22
|
|
20
23
|
plugin :ssh, Proxy::RemoteExecution::Ssh::VERSION
|
21
24
|
after_activation do
|
@@ -23,13 +26,16 @@ module Proxy::RemoteExecution::Ssh
|
|
23
26
|
require 'smart_proxy_remote_execution_ssh/version'
|
24
27
|
require 'smart_proxy_remote_execution_ssh/cockpit'
|
25
28
|
require 'smart_proxy_remote_execution_ssh/api'
|
26
|
-
require 'smart_proxy_remote_execution_ssh/actions
|
29
|
+
require 'smart_proxy_remote_execution_ssh/actions'
|
27
30
|
require 'smart_proxy_remote_execution_ssh/dispatcher'
|
28
31
|
require 'smart_proxy_remote_execution_ssh/log_filter'
|
29
32
|
require 'smart_proxy_remote_execution_ssh/runners'
|
30
|
-
require '
|
33
|
+
require 'smart_proxy_remote_execution_ssh/utils'
|
34
|
+
require 'smart_proxy_remote_execution_ssh/job_storage'
|
31
35
|
|
32
36
|
Proxy::RemoteExecution::Ssh.validate!
|
37
|
+
|
38
|
+
Proxy::Dynflow::TaskLauncherRegistry.register('ssh', Proxy::Dynflow::TaskLauncher::Batch)
|
33
39
|
end
|
34
40
|
|
35
41
|
def self.simulate?
|
@@ -39,7 +45,7 @@ module Proxy::RemoteExecution::Ssh
|
|
39
45
|
def self.runner_class
|
40
46
|
@runner_class ||= if simulate?
|
41
47
|
Runners::FakeScriptRunner
|
42
|
-
elsif settings
|
48
|
+
elsif settings.mode == :'ssh-async'
|
43
49
|
Runners::PollingScriptRunner
|
44
50
|
else
|
45
51
|
Runners::ScriptRunner
|
@@ -24,7 +24,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
24
24
|
@callback_host = options[:callback_host]
|
25
25
|
@task_id = options[:uuid]
|
26
26
|
@step_id = options[:step_id]
|
27
|
-
@otp =
|
27
|
+
@otp = Proxy::Dynflow::OtpManager.generate_otp(@task_id)
|
28
28
|
end
|
29
29
|
|
30
30
|
def prepare_start
|
@@ -40,7 +40,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
40
40
|
control_script_finish = "#{@control_script_path} init-script-finish"
|
41
41
|
<<-SCRIPT.gsub(/^ +\| /, '')
|
42
42
|
| export CONTROL_SCRIPT="#{@control_script_path}"
|
43
|
-
| sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
|
43
|
+
| #{@user_method.cli_command_prefix} sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
|
44
44
|
| echo $! > '#{@base_dir}/pid'
|
45
45
|
SCRIPT
|
46
46
|
end
|
@@ -90,7 +90,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
90
90
|
|
91
91
|
def close
|
92
92
|
super
|
93
|
-
|
93
|
+
Proxy::Dynflow::OtpManager.drop_otp(@task_id, @otp) if @otp
|
94
94
|
end
|
95
95
|
|
96
96
|
def upload_control_scripts
|
@@ -116,7 +116,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
116
116
|
|
117
117
|
# Generates updates based on the callback data from the manual mode
|
118
118
|
def load_event_updates(event_data)
|
119
|
-
continuous_output =
|
119
|
+
continuous_output = Proxy::Dynflow::ContinuousOutput.new
|
120
120
|
if event_data.key?('output')
|
121
121
|
lines = Base64.decode64(event_data['output']).sub(/\A(RUNNING|DONE).*\n/, '')
|
122
122
|
continuous_output.add_output(lines, 'stdout')
|
@@ -132,8 +132,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
132
132
|
def destroy_session
|
133
133
|
if @session
|
134
134
|
@logger.debug("Closing session with #{@ssh_user}@#{@host}")
|
135
|
-
|
136
|
-
@session = nil
|
135
|
+
close_session
|
137
136
|
end
|
138
137
|
end
|
139
138
|
end
|
@@ -1,12 +1,5 @@
|
|
1
|
-
require 'net/ssh'
|
2
1
|
require 'fileutils'
|
3
|
-
|
4
|
-
# Rubocop can't make up its mind what it wants
|
5
|
-
# rubocop:disable Lint/SuppressedException, Lint/RedundantCopDisableDirective
|
6
|
-
begin
|
7
|
-
require 'net/ssh/krb'
|
8
|
-
rescue LoadError; end
|
9
|
-
# rubocop:enable Lint/SuppressedException, Lint/RedundantCopDisableDirective
|
2
|
+
require 'smart_proxy_dynflow/runner/command'
|
10
3
|
|
11
4
|
module Proxy::RemoteExecution::Ssh::Runners
|
12
5
|
class EffectiveUserMethod
|
@@ -21,7 +14,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
21
14
|
|
22
15
|
def on_data(received_data, ssh_channel)
|
23
16
|
if received_data.match(login_prompt)
|
24
|
-
ssh_channel.
|
17
|
+
ssh_channel.puts(effective_user_password)
|
25
18
|
@password_sent = true
|
26
19
|
end
|
27
20
|
end
|
@@ -96,12 +89,12 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
96
89
|
end
|
97
90
|
|
98
91
|
# rubocop:disable Metrics/ClassLength
|
99
|
-
class ScriptRunner <
|
92
|
+
class ScriptRunner < Proxy::Dynflow::Runner::Base
|
93
|
+
include Proxy::Dynflow::Runner::Command
|
100
94
|
attr_reader :execution_timeout_interval
|
101
95
|
|
102
96
|
EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
|
103
97
|
DEFAULT_REFRESH_INTERVAL = 1
|
104
|
-
MAX_PROCESS_RETRIES = 4
|
105
98
|
|
106
99
|
def initialize(options, user_method, suspended_action: nil)
|
107
100
|
super suspended_action: suspended_action
|
@@ -119,6 +112,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
119
112
|
@local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
|
120
113
|
@remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir)
|
121
114
|
@cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
|
115
|
+
@first_execution = options.fetch(:first_execution, false)
|
122
116
|
@user_method = user_method
|
123
117
|
end
|
124
118
|
|
@@ -146,12 +140,13 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
146
140
|
end
|
147
141
|
|
148
142
|
def start
|
143
|
+
Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
|
149
144
|
prepare_start
|
150
145
|
script = initialization_script
|
151
146
|
logger.debug("executing script:\n#{indent_multiline(script)}")
|
152
147
|
trigger(script)
|
153
|
-
rescue StandardError => e
|
154
|
-
logger.error("error while
|
148
|
+
rescue StandardError, NotImplementedError => e
|
149
|
+
logger.error("error while initializing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
|
155
150
|
publish_exception('Error initializing command', e)
|
156
151
|
end
|
157
152
|
|
@@ -177,12 +172,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
177
172
|
|
178
173
|
def refresh
|
179
174
|
return if @session.nil?
|
180
|
-
|
181
|
-
with_retries do
|
182
|
-
with_disconnect_handling do
|
183
|
-
@session.process(0)
|
184
|
-
end
|
185
|
-
end
|
175
|
+
super
|
186
176
|
ensure
|
187
177
|
check_expecting_disconnect
|
188
178
|
end
|
@@ -206,32 +196,15 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
206
196
|
execution_timeout_interval
|
207
197
|
end
|
208
198
|
|
209
|
-
def
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
retry
|
219
|
-
else
|
220
|
-
publish_exception('Unexpected error', e)
|
221
|
-
end
|
222
|
-
end
|
223
|
-
end
|
224
|
-
|
225
|
-
def with_disconnect_handling
|
226
|
-
yield
|
227
|
-
rescue IOError, Net::SSH::Disconnect => e
|
228
|
-
@session.shutdown!
|
229
|
-
check_expecting_disconnect
|
230
|
-
if @expecting_disconnect
|
231
|
-
publish_exit_status(0)
|
232
|
-
else
|
233
|
-
publish_exception('Unexpected disconnect', e)
|
234
|
-
end
|
199
|
+
def close_session
|
200
|
+
@session = nil
|
201
|
+
raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
|
202
|
+
@logger.debug("Sending exit request for session #{@ssh_user}@#{@host}")
|
203
|
+
args = ['/usr/bin/ssh', @host, "-o", "User=#{@ssh_user}", "-o", "ControlPath=#{local_command_file("socket")}", "-O", "exit"].flatten
|
204
|
+
pid, *, err = session(args, in_stream: false, out_stream: false)
|
205
|
+
result = read_output_debug(err)
|
206
|
+
Process.wait(pid)
|
207
|
+
result
|
235
208
|
end
|
236
209
|
|
237
210
|
def close
|
@@ -239,12 +212,13 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
239
212
|
rescue StandardError => e
|
240
213
|
publish_exception('Error when removing remote working dir', e, false)
|
241
214
|
ensure
|
242
|
-
|
215
|
+
close_session if @session
|
243
216
|
FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
|
244
217
|
end
|
245
218
|
|
246
219
|
def publish_data(data, type)
|
247
|
-
super(data.force_encoding('UTF-8'), type)
|
220
|
+
super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
|
221
|
+
@user_method.on_data(data, @command_in)
|
248
222
|
end
|
249
223
|
|
250
224
|
private
|
@@ -254,38 +228,54 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
254
228
|
end
|
255
229
|
|
256
230
|
def should_cleanup?
|
257
|
-
@session &&
|
258
|
-
end
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
ssh_options
|
279
|
-
ssh_options
|
280
|
-
ssh_options
|
281
|
-
ssh_options
|
282
|
-
|
231
|
+
@session && @cleanup_working_dirs
|
232
|
+
end
|
233
|
+
|
234
|
+
# Creates session with three pipes - one for reading and two for
|
235
|
+
# writing. Similar to `Open3.popen3` method but without creating
|
236
|
+
# a separate thread to monitor it.
|
237
|
+
def session(args, in_stream: true, out_stream: true, err_stream: true)
|
238
|
+
@session = true
|
239
|
+
|
240
|
+
in_read, in_write = in_stream ? IO.pipe : '/dev/null'
|
241
|
+
out_read, out_write = out_stream ? IO.pipe : [nil, '/dev/null']
|
242
|
+
err_read, err_write = err_stream ? IO.pipe : [nil, '/dev/null']
|
243
|
+
command_pid = spawn(*args, :in => in_read, :out => out_write, :err => err_write)
|
244
|
+
in_read.close if in_stream
|
245
|
+
out_write.close if out_stream
|
246
|
+
err_write.close if err_stream
|
247
|
+
|
248
|
+
return command_pid, in_write, out_read, err_read
|
249
|
+
end
|
250
|
+
|
251
|
+
def ssh_options(with_pty = false)
|
252
|
+
ssh_options = []
|
253
|
+
ssh_options << "-tt" if with_pty
|
254
|
+
ssh_options << "-o User=#{@ssh_user}"
|
255
|
+
ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
|
256
|
+
ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
|
257
|
+
ssh_options << "-o IdentitiesOnly=yes"
|
258
|
+
ssh_options << "-o StrictHostKeyChecking=no"
|
259
|
+
ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
|
260
|
+
ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
|
261
|
+
ssh_options << "-o NumberOfPasswordPrompts=1"
|
262
|
+
ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
|
263
|
+
ssh_options << "-o ControlMaster=auto"
|
264
|
+
ssh_options << "-o ControlPath=#{local_command_file("socket")}"
|
265
|
+
ssh_options << "-o ControlPersist=yes"
|
283
266
|
end
|
284
267
|
|
285
268
|
def settings
|
286
269
|
Proxy::RemoteExecution::Ssh::Plugin.settings
|
287
270
|
end
|
288
271
|
|
272
|
+
def get_args(command, with_pty = false)
|
273
|
+
args = []
|
274
|
+
args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
|
275
|
+
args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
|
276
|
+
args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
|
277
|
+
end
|
278
|
+
|
289
279
|
# Initiates run of the remote command and yields the data when
|
290
280
|
# available. The yielding doesn't happen automatically, but as
|
291
281
|
# part of calling the `refresh` method.
|
@@ -294,30 +284,9 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
294
284
|
|
295
285
|
@started = false
|
296
286
|
@user_method.reset
|
287
|
+
@command_pid, @command_in, @command_out = session(get_args(command, with_pty: true), err_stream: false)
|
288
|
+
@started = true
|
297
289
|
|
298
|
-
session.open_channel do |channel|
|
299
|
-
channel.request_pty
|
300
|
-
channel.on_data do |ch, data|
|
301
|
-
publish_data(data, 'stdout') unless @user_method.filter_password?(data)
|
302
|
-
@user_method.on_data(data, ch)
|
303
|
-
end
|
304
|
-
channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
|
305
|
-
# standard exit of the command
|
306
|
-
channel.on_request('exit-status') { |ch, data| publish_exit_status(data.read_long) }
|
307
|
-
# on signal: sending the signal value (such as 'TERM')
|
308
|
-
channel.on_request('exit-signal') do |ch, data|
|
309
|
-
publish_exit_status(data.read_string)
|
310
|
-
ch.close
|
311
|
-
# wait for the channel to finish so that we know at the end
|
312
|
-
# that the session is inactive
|
313
|
-
ch.wait
|
314
|
-
end
|
315
|
-
channel.exec(command) do |_, success|
|
316
|
-
@started = true
|
317
|
-
raise('Error initializing command') unless success
|
318
|
-
end
|
319
|
-
end
|
320
|
-
session.process(0) { !run_started? }
|
321
290
|
return true
|
322
291
|
end
|
323
292
|
|
@@ -325,36 +294,27 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
325
294
|
@started && @user_method.sent_all_data?
|
326
295
|
end
|
327
296
|
|
328
|
-
def
|
297
|
+
def read_output_debug(err_io, out_io = nil)
|
329
298
|
stdout = ''
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
ch.on_data do |c, data|
|
336
|
-
stdout.concat(data)
|
337
|
-
end
|
338
|
-
ch.on_extended_data { |_, _, data| stderr.concat(data) }
|
339
|
-
ch.on_request('exit-status') { |_, data| exit_status = data.read_long }
|
340
|
-
# Send data to stdin if we have some
|
341
|
-
ch.send_data(stdin) unless stdin.nil?
|
342
|
-
# on signal: sending the signal value (such as 'TERM')
|
343
|
-
ch.on_request('exit-signal') do |_, data|
|
344
|
-
exit_status = data.read_string
|
345
|
-
ch.close
|
346
|
-
ch.wait
|
347
|
-
end
|
348
|
-
ch.exec command do |_, success|
|
349
|
-
raise 'could not execute command' unless success
|
350
|
-
|
351
|
-
started = true
|
352
|
-
end
|
299
|
+
debug_str = ''
|
300
|
+
|
301
|
+
if out_io
|
302
|
+
stdout += out_io.read until out_io.eof? rescue
|
303
|
+
out_io.close
|
353
304
|
end
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
305
|
+
debug_str += err_io.read until err_io.eof? rescue
|
306
|
+
err_io.close
|
307
|
+
debug_str.lines.each { |line| @logger.debug(line.strip) }
|
308
|
+
|
309
|
+
return stdout, debug_str
|
310
|
+
end
|
311
|
+
|
312
|
+
def run_sync(command, stdin = nil)
|
313
|
+
pid, tx, rx, err = session(get_args(command))
|
314
|
+
tx.puts(stdin) unless stdin.nil?
|
315
|
+
tx.close
|
316
|
+
stdout, stderr = read_output_debug(err, rx)
|
317
|
+
exit_status = Process.wait2(pid)[1].exitstatus
|
358
318
|
return exit_status, stdout, stderr
|
359
319
|
end
|
360
320
|
|
@@ -371,7 +331,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
371
331
|
end
|
372
332
|
|
373
333
|
def local_command_file(filename)
|
374
|
-
File.join(local_command_dir, filename)
|
334
|
+
File.join(ensure_local_directory(local_command_dir), filename)
|
375
335
|
end
|
376
336
|
|
377
337
|
def remote_command_dir
|
@@ -453,15 +413,8 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
453
413
|
|
454
414
|
def available_authentication_methods
|
455
415
|
methods = %w[publickey] # Always use pubkey auth as fallback
|
456
|
-
if settings[:kerberos_auth]
|
457
|
-
if defined? Net::SSH::Kerberos
|
458
|
-
methods << 'gssapi-with-mic'
|
459
|
-
else
|
460
|
-
@logger.warn('Kerberos authentication requested but not available')
|
461
|
-
end
|
462
|
-
end
|
416
|
+
methods << 'gssapi-with-mic' if settings[:kerberos_auth]
|
463
417
|
methods.unshift('password') if @ssh_password
|
464
|
-
|
465
418
|
methods
|
466
419
|
end
|
467
420
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'open3'
|
2
|
+
|
3
|
+
module Proxy::RemoteExecution
|
4
|
+
module Utils
|
5
|
+
class << self
|
6
|
+
def prune_known_hosts!(hostname, port, logger = Logger.new($stdout))
|
7
|
+
return if Net::SSH::KnownHosts.search_for(hostname).empty?
|
8
|
+
|
9
|
+
target = if port == 22
|
10
|
+
hostname
|
11
|
+
else
|
12
|
+
"[#{hostname}]:#{port}"
|
13
|
+
end
|
14
|
+
|
15
|
+
Open3.popen3('ssh-keygen', '-R', target) do |_stdin, stdout, _stderr, wait_thr|
|
16
|
+
wait_thr.join
|
17
|
+
stdout.read
|
18
|
+
end
|
19
|
+
rescue Errno::ENOENT => e
|
20
|
+
logger.warn("Could not remove #{hostname} from know_hosts: #{e}")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|