smart_proxy_remote_execution_ssh 0.5.3 → 0.7.1
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/lib/smart_proxy_remote_execution_ssh/actions/pull_script.rb +72 -14
- data/lib/smart_proxy_remote_execution_ssh/api.rb +9 -7
- data/lib/smart_proxy_remote_execution_ssh/cockpit.rb +3 -2
- data/lib/smart_proxy_remote_execution_ssh/net_ssh_compat.rb +6 -2
- data/lib/smart_proxy_remote_execution_ssh/plugin.rb +10 -1
- data/lib/smart_proxy_remote_execution_ssh/runners/polling_script_runner.rb +15 -7
- data/lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb +101 -82
- data/lib/smart_proxy_remote_execution_ssh/version.rb +1 -1
- data/lib/smart_proxy_remote_execution_ssh.rb +35 -14
- data/settings.d/remote_execution_ssh.yml.example +8 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65bdd1bbf94900594e4a6acf2d3c1522e778dc7ca5eff5c70b085a5cf9b077c9
|
|
4
|
+
data.tar.gz: 7c1321bd2b69b5e4498df66b000814cffaffcc770220e09648c98534e60ce900
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 439a6ac3a27140f930a52806e6708d1961343de024b0b5398be70add77be7a0a095a609c03c282371e9c0f8f6dead04b906b9fcb64618463594e69e5ab2c0290
|
|
7
|
+
data.tar.gz: 54cfbbbc9001fef48bf7ea607ad68b507fcd5610213ec81e9176d705364b0c5f207fbaed441102a645d25f86ff3d2a653fdce69a019daf83f84e45fa6e098315
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'mqtt'
|
|
2
2
|
require 'json'
|
|
3
|
+
require 'time'
|
|
3
4
|
|
|
4
5
|
module Proxy::RemoteExecution::Ssh::Actions
|
|
5
6
|
class PullScript < Proxy::Dynflow::Action::Runner
|
|
@@ -25,7 +26,8 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
|
25
26
|
otp_password = if input[:with_mqtt]
|
|
26
27
|
::Proxy::Dynflow::OtpManager.generate_otp(execution_plan_id)
|
|
27
28
|
end
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
input[:job_uuid] = job_storage.store_job(host_name, execution_plan_id, run_step_id, input[:script].tr("\r", ''))
|
|
29
31
|
output[:state] = :ready_for_pickup
|
|
30
32
|
output[:result] = []
|
|
31
33
|
mqtt_start(otp_password) if input[:with_mqtt]
|
|
@@ -40,9 +42,40 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
|
40
42
|
def process_external_event(event)
|
|
41
43
|
output[:state] = :running
|
|
42
44
|
data = event.data
|
|
45
|
+
case data['version']
|
|
46
|
+
when nil
|
|
47
|
+
process_external_unversioned(data)
|
|
48
|
+
when '1'
|
|
49
|
+
process_external_v1(data)
|
|
50
|
+
else
|
|
51
|
+
raise "Unexpected update message version '#{data['version']}'"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def process_external_unversioned(payload)
|
|
56
|
+
continuous_output = Proxy::Dynflow::ContinuousOutput.new
|
|
57
|
+
Array(payload['output']).each { |line| continuous_output.add_output(line, payload['type']) } if payload.key?('output')
|
|
58
|
+
exit_code = payload['exit_code'].to_i if payload['exit_code']
|
|
59
|
+
process_update(Proxy::Dynflow::Runner::Update.new(continuous_output, exit_code))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def process_external_v1(payload)
|
|
43
63
|
continuous_output = Proxy::Dynflow::ContinuousOutput.new
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
exit_code = nil
|
|
65
|
+
|
|
66
|
+
payload['updates'].each do |update|
|
|
67
|
+
time = Time.parse update['timestamp']
|
|
68
|
+
type = update['type']
|
|
69
|
+
case type
|
|
70
|
+
when 'output'
|
|
71
|
+
continuous_output.add_output(update['content'], update['stream'], time)
|
|
72
|
+
when 'exit'
|
|
73
|
+
exit_code = update['exit_code'].to_i
|
|
74
|
+
else
|
|
75
|
+
raise "Unexpected update type '#{update['type']}'"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
46
79
|
process_update(Proxy::Dynflow::Runner::Update.new(continuous_output, exit_code))
|
|
47
80
|
end
|
|
48
81
|
|
|
@@ -56,37 +89,52 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
|
56
89
|
# Client was notified or is already running, dealing with this situation
|
|
57
90
|
# is only supported if mqtt is available
|
|
58
91
|
# Otherwise we have to wait it out
|
|
59
|
-
|
|
60
|
-
# if input[:with_mqtt]
|
|
92
|
+
mqtt_cancel if input[:with_mqtt]
|
|
61
93
|
end
|
|
62
94
|
suspend
|
|
63
95
|
end
|
|
64
96
|
|
|
65
97
|
def mqtt_start(otp_password)
|
|
66
|
-
payload =
|
|
67
|
-
|
|
68
|
-
message_id: SecureRandom.uuid,
|
|
69
|
-
version: 1,
|
|
70
|
-
sent: DateTime.now.iso8601,
|
|
71
|
-
directive: 'foreman',
|
|
98
|
+
payload = mqtt_payload_base.merge(
|
|
99
|
+
content: "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}",
|
|
72
100
|
metadata: {
|
|
101
|
+
'event': 'start',
|
|
73
102
|
'job_uuid': input[:job_uuid],
|
|
74
103
|
'username': execution_plan_id,
|
|
75
104
|
'password': otp_password,
|
|
76
105
|
'return_url': "#{input[:proxy_url]}/ssh/jobs/#{input[:job_uuid]}/update",
|
|
77
106
|
},
|
|
78
|
-
|
|
79
|
-
}
|
|
107
|
+
)
|
|
80
108
|
mqtt_notify payload
|
|
81
109
|
output[:state] = :notified
|
|
82
110
|
end
|
|
83
111
|
|
|
112
|
+
def mqtt_cancel
|
|
113
|
+
cleanup
|
|
114
|
+
payload = mqtt_payload_base.merge(
|
|
115
|
+
metadata: {
|
|
116
|
+
'event': 'cancel',
|
|
117
|
+
'job_uuid': input[:job_uuid]
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
mqtt_notify payload
|
|
121
|
+
end
|
|
122
|
+
|
|
84
123
|
def mqtt_notify(payload)
|
|
85
|
-
|
|
124
|
+
with_mqtt_client do |c|
|
|
86
125
|
c.publish(mqtt_topic, JSON.dump(payload), false, 1)
|
|
87
126
|
end
|
|
88
127
|
end
|
|
89
128
|
|
|
129
|
+
def with_mqtt_client(&block)
|
|
130
|
+
MQTT::Client.connect(settings.mqtt_broker, settings.mqtt_port,
|
|
131
|
+
:ssl => settings.mqtt_tls,
|
|
132
|
+
:cert_file => ::Proxy::SETTINGS.foreman_ssl_cert || ::Proxy::SETTINGS.ssl_certificate,
|
|
133
|
+
:key_file => ::Proxy::SETTINGS.foreman_ssl_key || ::Proxy::SETTINGS.ssl_private_key,
|
|
134
|
+
:ca_file => ::Proxy::SETTINGS.foreman_ssl_ca || ::Proxy::SETTINGS.ssl_ca_file,
|
|
135
|
+
&block)
|
|
136
|
+
end
|
|
137
|
+
|
|
90
138
|
def host_name
|
|
91
139
|
alternative_names = input.fetch(:alternative_names, {})
|
|
92
140
|
|
|
@@ -106,5 +154,15 @@ module Proxy::RemoteExecution::Ssh::Actions
|
|
|
106
154
|
def job_storage
|
|
107
155
|
Proxy::RemoteExecution::Ssh.job_storage
|
|
108
156
|
end
|
|
157
|
+
|
|
158
|
+
def mqtt_payload_base
|
|
159
|
+
{
|
|
160
|
+
type: 'data',
|
|
161
|
+
message_id: SecureRandom.uuid,
|
|
162
|
+
version: 1,
|
|
163
|
+
sent: DateTime.now.iso8601,
|
|
164
|
+
directive: 'foreman'
|
|
165
|
+
}
|
|
166
|
+
end
|
|
109
167
|
end
|
|
110
168
|
end
|
|
@@ -13,14 +13,16 @@ module Proxy::RemoteExecution
|
|
|
13
13
|
File.read(Ssh.public_key_file)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
if Proxy::RemoteExecution::Ssh::Plugin.settings.cockpit_integration
|
|
17
|
+
post "/session" do
|
|
18
|
+
do_authorize_any
|
|
19
|
+
session = Cockpit::Session.new(env)
|
|
20
|
+
unless session.valid?
|
|
21
|
+
return [ 400, "Invalid request: /ssh/session requires connection upgrade to 'raw'" ]
|
|
22
|
+
end
|
|
23
|
+
session.hijack!
|
|
24
|
+
101
|
|
21
25
|
end
|
|
22
|
-
session.hijack!
|
|
23
|
-
101
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
delete '/known_hosts/:name' do |name|
|
|
@@ -180,10 +180,11 @@ module Proxy::RemoteExecution
|
|
|
180
180
|
|
|
181
181
|
def inner_system_ssh_loop(out_buf, err_buf, in_buf, pid)
|
|
182
182
|
err_buf_raw = ''
|
|
183
|
-
readers = [buf_socket, out_buf, err_buf]
|
|
184
183
|
loop do
|
|
184
|
+
readers = [buf_socket, out_buf, err_buf].reject { |io| io.closed? }
|
|
185
|
+
writers = [buf_socket, in_buf].select { |io| io.pending_writes? }
|
|
185
186
|
# Prime the sockets for reading
|
|
186
|
-
ready_readers, ready_writers = IO.select(readers,
|
|
187
|
+
ready_readers, ready_writers = IO.select(readers, writers)
|
|
187
188
|
(ready_readers || []).each { |reader| reader.close if reader.fill.zero? }
|
|
188
189
|
|
|
189
190
|
proxy_data(out_buf, in_buf)
|
|
@@ -173,10 +173,14 @@ module Proxy::RemoteExecution
|
|
|
173
173
|
output.append(data)
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
def pending_writes?
|
|
177
|
+
output.length.positive?
|
|
178
|
+
end
|
|
179
|
+
|
|
176
180
|
# Sends as much of the pending output as possible. Returns +true+ if any
|
|
177
181
|
# data was sent, and +false+ otherwise.
|
|
178
182
|
def send_pending
|
|
179
|
-
if
|
|
183
|
+
if pending_writes?
|
|
180
184
|
sent = send(output.to_s, 0)
|
|
181
185
|
output.consume!(sent)
|
|
182
186
|
return sent.positive?
|
|
@@ -189,7 +193,7 @@ module Proxy::RemoteExecution
|
|
|
189
193
|
# buffer is empty.
|
|
190
194
|
def wait_for_pending_sends
|
|
191
195
|
send_pending
|
|
192
|
-
while
|
|
196
|
+
while pending_writes?
|
|
193
197
|
result = IO.select(nil, [self]) || next
|
|
194
198
|
next unless result[1].any?
|
|
195
199
|
|
|
@@ -2,6 +2,10 @@ module Proxy::RemoteExecution::Ssh
|
|
|
2
2
|
class Plugin < Proxy::Plugin
|
|
3
3
|
SSH_LOG_LEVELS = %w[debug info error fatal].freeze
|
|
4
4
|
MODES = %i[ssh async-ssh pull pull-mqtt].freeze
|
|
5
|
+
# Unix domain socket path length is limited to 104 (on some platforms) characters
|
|
6
|
+
# Socket path is composed of custom path (max 49 characters) + job id (37 characters)
|
|
7
|
+
# + offset(17 characters) + null terminator
|
|
8
|
+
SOCKET_PATH_MAX_LENGTH = 49
|
|
5
9
|
|
|
6
10
|
http_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
|
|
7
11
|
https_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
|
|
@@ -11,16 +15,21 @@ module Proxy::RemoteExecution::Ssh
|
|
|
11
15
|
:ssh_user => 'root',
|
|
12
16
|
:remote_working_dir => '/var/tmp',
|
|
13
17
|
:local_working_dir => '/var/tmp',
|
|
18
|
+
:socket_working_dir => '/var/tmp',
|
|
14
19
|
:kerberos_auth => false,
|
|
20
|
+
:cockpit_integration => true,
|
|
15
21
|
# When set to nil, makes REX use the runner's default interval
|
|
16
22
|
# :runner_refresh_interval => nil,
|
|
17
23
|
:ssh_log_level => :error,
|
|
18
24
|
:cleanup_working_dirs => true,
|
|
19
25
|
# :mqtt_broker => nil,
|
|
20
26
|
# :mqtt_port => nil,
|
|
27
|
+
# :mqtt_tls => nil,
|
|
21
28
|
:mode => :ssh
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
capability(proc { 'cockpit' if settings.cockpit_integration })
|
|
31
|
+
|
|
32
|
+
plugin :script, Proxy::RemoteExecution::Ssh::VERSION
|
|
24
33
|
after_activation do
|
|
25
34
|
require 'smart_proxy_dynflow'
|
|
26
35
|
require 'smart_proxy_remote_execution_ssh/version'
|
|
@@ -36,13 +36,12 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
36
36
|
def initialization_script
|
|
37
37
|
close_stdin = '</dev/null'
|
|
38
38
|
close_fds = close_stdin + ' >/dev/null 2>/dev/null'
|
|
39
|
-
main_script = "(#{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
|
|
39
|
+
main_script = "(#{@remote_script_wrapper} #{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
|
|
40
40
|
control_script_finish = "#{@control_script_path} init-script-finish"
|
|
41
41
|
<<-SCRIPT.gsub(/^ +\| /, '')
|
|
42
42
|
| export CONTROL_SCRIPT="#{@control_script_path}"
|
|
43
|
-
| #{"chown #{@user_method.effective_user}
|
|
43
|
+
| #{"chown #{@user_method.effective_user} #{@base_dir}" if @user_method.cli_command_prefix}
|
|
44
44
|
| #{@user_method.cli_command_prefix} sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
|
|
45
|
-
| echo $! > '#{@base_dir}/pid'
|
|
46
45
|
SCRIPT
|
|
47
46
|
end
|
|
48
47
|
|
|
@@ -51,18 +50,23 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
def refresh
|
|
54
|
-
err = output = nil
|
|
55
53
|
begin
|
|
56
|
-
|
|
54
|
+
pm = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
|
|
57
55
|
rescue StandardError => e
|
|
58
56
|
@logger.info("Error while connecting to the remote host on refresh: #{e.message}")
|
|
59
57
|
end
|
|
60
58
|
|
|
61
|
-
process_retrieved_data(
|
|
59
|
+
process_retrieved_data(pm.stdout.to_s.chomp, pm.stderr.to_s.chomp)
|
|
62
60
|
ensure
|
|
63
61
|
destroy_session
|
|
64
62
|
end
|
|
65
63
|
|
|
64
|
+
def kill
|
|
65
|
+
run_sync("pkill -P $(cat #{@pid_path})")
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
publish_exception('Unexpected error', e, false)
|
|
68
|
+
end
|
|
69
|
+
|
|
66
70
|
def process_retrieved_data(output, err)
|
|
67
71
|
return if output.nil? || output.empty?
|
|
68
72
|
|
|
@@ -127,7 +131,11 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
127
131
|
end
|
|
128
132
|
|
|
129
133
|
def cleanup
|
|
130
|
-
|
|
134
|
+
if @cleanup_working_dirs
|
|
135
|
+
ensure_remote_command("rm -rf #{remote_command_dir}",
|
|
136
|
+
publish: true,
|
|
137
|
+
error: "Unable to remove working directory #{remote_command_dir} on remote system, exit code: %{exit_code}")
|
|
138
|
+
end
|
|
131
139
|
end
|
|
132
140
|
|
|
133
141
|
def destroy_session
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
|
-
require 'smart_proxy_dynflow/runner/
|
|
2
|
+
require 'smart_proxy_dynflow/runner/process_manager_command'
|
|
3
|
+
require 'smart_proxy_dynflow/process_manager'
|
|
3
4
|
|
|
4
5
|
module Proxy::RemoteExecution::Ssh::Runners
|
|
5
6
|
class EffectiveUserMethod
|
|
@@ -12,9 +13,9 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
12
13
|
@password_sent = false
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
def on_data(received_data,
|
|
16
|
+
def on_data(received_data, io_buffer)
|
|
16
17
|
if received_data.match(login_prompt)
|
|
17
|
-
|
|
18
|
+
io_buffer.add_data(effective_user_password + "\n")
|
|
18
19
|
@password_sent = true
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -90,7 +91,7 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
90
91
|
|
|
91
92
|
# rubocop:disable Metrics/ClassLength
|
|
92
93
|
class ScriptRunner < Proxy::Dynflow::Runner::Base
|
|
93
|
-
include Proxy::Dynflow::Runner::
|
|
94
|
+
include Proxy::Dynflow::Runner::ProcessManagerCommand
|
|
94
95
|
attr_reader :execution_timeout_interval
|
|
95
96
|
|
|
96
97
|
EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
|
|
@@ -110,7 +111,8 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
110
111
|
|
|
111
112
|
@client_private_key_file = settings.ssh_identity_key_file
|
|
112
113
|
@local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
|
|
113
|
-
@remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir)
|
|
114
|
+
@remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir.shellescape)
|
|
115
|
+
@socket_working_dir = options.fetch(:socket_working_dir, settings.socket_working_dir)
|
|
114
116
|
@cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
|
|
115
117
|
@first_execution = options.fetch(:first_execution, false)
|
|
116
118
|
@user_method = user_method
|
|
@@ -141,6 +143,8 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
141
143
|
|
|
142
144
|
def start
|
|
143
145
|
Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
|
|
146
|
+
establish_connection
|
|
147
|
+
preflight_checks
|
|
144
148
|
prepare_start
|
|
145
149
|
script = initialization_script
|
|
146
150
|
logger.debug("executing script:\n#{indent_multiline(script)}")
|
|
@@ -154,32 +158,61 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
154
158
|
run_async(*args)
|
|
155
159
|
end
|
|
156
160
|
|
|
161
|
+
def preflight_checks
|
|
162
|
+
ensure_remote_command(cp_script_to_remote("#!/bin/sh\nexec true", 'test'),
|
|
163
|
+
error: 'Failed to execute script on remote machine, exit code: %{exit_code}.'
|
|
164
|
+
)
|
|
165
|
+
unless @user_method.is_a? NoopUserMethod
|
|
166
|
+
path = cp_script_to_remote("#!/bin/sh\nexec #{@user_method.cli_command_prefix} true", 'effective-user-test')
|
|
167
|
+
ensure_remote_command(path,
|
|
168
|
+
error: 'Failed to change to effective user, exit code: %{exit_code}',
|
|
169
|
+
tty: true,
|
|
170
|
+
user_method: @user_method,
|
|
171
|
+
close_stdin: false)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def establish_connection
|
|
176
|
+
# run_sync ['-f', '-N'] would be cleaner, but ssh does not close its
|
|
177
|
+
# stderr which trips up the process manager which expects all FDs to be
|
|
178
|
+
# closed
|
|
179
|
+
ensure_remote_command(
|
|
180
|
+
'true',
|
|
181
|
+
error: 'Failed to establish connection to remote host, exit code: %{exit_code}'
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
157
185
|
def prepare_start
|
|
158
186
|
@remote_script = cp_script_to_remote
|
|
159
187
|
@output_path = File.join(File.dirname(@remote_script), 'output')
|
|
160
188
|
@exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
|
|
189
|
+
@pid_path = File.join(File.dirname(@remote_script), 'pid')
|
|
190
|
+
@remote_script_wrapper = upload_data("echo $$ > #{@pid_path}; exec \"$@\";", File.join(File.dirname(@remote_script), 'script-wrapper'), 555)
|
|
161
191
|
end
|
|
162
192
|
|
|
163
193
|
# the script that initiates the execution
|
|
164
194
|
def initialization_script
|
|
165
195
|
su_method = @user_method.instance_of?(SuUserMethod)
|
|
166
196
|
# pipe the output to tee while capturing the exit code in a file
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
197
|
+
<<~SCRIPT
|
|
198
|
+
sh <<EOF | /usr/bin/tee #{@output_path}
|
|
199
|
+
#{@remote_script_wrapper} #{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}
|
|
200
|
+
echo \\$?>#{@exit_code_path}
|
|
201
|
+
EOF
|
|
202
|
+
exit $(cat #{@exit_code_path})
|
|
170
203
|
SCRIPT
|
|
171
204
|
end
|
|
172
205
|
|
|
173
206
|
def refresh
|
|
174
|
-
return if @
|
|
207
|
+
return if @process_manager.nil?
|
|
175
208
|
super
|
|
176
209
|
ensure
|
|
177
210
|
check_expecting_disconnect
|
|
178
211
|
end
|
|
179
212
|
|
|
180
213
|
def kill
|
|
181
|
-
if @
|
|
182
|
-
run_sync("pkill -
|
|
214
|
+
if @process_manager&.started?
|
|
215
|
+
run_sync("pkill -P $(cat #{@pid_path})")
|
|
183
216
|
else
|
|
184
217
|
logger.debug('connection closed')
|
|
185
218
|
end
|
|
@@ -197,28 +230,28 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
197
230
|
end
|
|
198
231
|
|
|
199
232
|
def close_session
|
|
200
|
-
|
|
201
|
-
raise 'Control socket file does not exist' unless File.exist?(local_command_file("socket"))
|
|
233
|
+
raise 'Control socket file does not exist' unless File.exist?(socket_file)
|
|
202
234
|
@logger.debug("Sending exit request for session #{@ssh_user}@#{@host}")
|
|
203
|
-
args = ['/usr/bin/ssh', @host, "-o", "
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
235
|
+
args = ['/usr/bin/ssh', @host, "-o", "ControlPath=#{socket_file}", "-O", "exit"].flatten
|
|
236
|
+
pm = Proxy::Dynflow::ProcessManager.new(args)
|
|
237
|
+
pm.on_stdout { |data| @logger.debug "[close_session]: #{data.chomp}"; data }
|
|
238
|
+
pm.on_stderr { |data| @logger.debug "[close_session]: #{data.chomp}"; data }
|
|
239
|
+
pm.run!
|
|
208
240
|
end
|
|
209
241
|
|
|
210
242
|
def close
|
|
211
|
-
run_sync("rm -rf
|
|
243
|
+
run_sync("rm -rf #{remote_command_dir}") if should_cleanup?
|
|
212
244
|
rescue StandardError => e
|
|
213
245
|
publish_exception('Error when removing remote working dir', e, false)
|
|
214
246
|
ensure
|
|
215
|
-
close_session if @
|
|
247
|
+
close_session if @process_manager
|
|
216
248
|
FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
|
|
217
249
|
end
|
|
218
250
|
|
|
219
|
-
def publish_data(data, type)
|
|
251
|
+
def publish_data(data, type, pm = nil)
|
|
252
|
+
pm ||= @process_manager
|
|
220
253
|
super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
|
|
221
|
-
@user_method.on_data(data,
|
|
254
|
+
@user_method.on_data(data, pm.stdin) if pm
|
|
222
255
|
end
|
|
223
256
|
|
|
224
257
|
private
|
|
@@ -228,27 +261,10 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
228
261
|
end
|
|
229
262
|
|
|
230
263
|
def should_cleanup?
|
|
231
|
-
@
|
|
264
|
+
@process_manager && @cleanup_working_dirs
|
|
232
265
|
end
|
|
233
266
|
|
|
234
|
-
|
|
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)
|
|
267
|
+
def ssh_options(with_pty = false, quiet: false)
|
|
252
268
|
ssh_options = []
|
|
253
269
|
ssh_options << "-tt" if with_pty
|
|
254
270
|
ssh_options << "-o User=#{@ssh_user}"
|
|
@@ -259,63 +275,58 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
259
275
|
ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
|
|
260
276
|
ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
|
|
261
277
|
ssh_options << "-o NumberOfPasswordPrompts=1"
|
|
262
|
-
ssh_options << "-o LogLevel=#{settings[:ssh_log_level]}"
|
|
278
|
+
ssh_options << "-o LogLevel=#{quiet ? 'quiet' : settings[:ssh_log_level]}"
|
|
263
279
|
ssh_options << "-o ControlMaster=auto"
|
|
264
|
-
ssh_options << "-o ControlPath=#{
|
|
280
|
+
ssh_options << "-o ControlPath=#{socket_file}"
|
|
265
281
|
ssh_options << "-o ControlPersist=yes"
|
|
282
|
+
ssh_options << "-o ProxyCommand=none"
|
|
266
283
|
end
|
|
267
284
|
|
|
268
285
|
def settings
|
|
269
286
|
Proxy::RemoteExecution::Ssh::Plugin.settings
|
|
270
287
|
end
|
|
271
288
|
|
|
272
|
-
def get_args(command, with_pty = false)
|
|
289
|
+
def get_args(command, with_pty = false, quiet: false)
|
|
273
290
|
args = []
|
|
274
291
|
args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
|
|
275
292
|
args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
|
|
276
|
-
args += ['/usr/bin/ssh', @host, ssh_options(with_pty), command].flatten
|
|
293
|
+
args += ['/usr/bin/ssh', @host, ssh_options(with_pty, quiet: quiet), command].flatten
|
|
277
294
|
end
|
|
278
295
|
|
|
279
296
|
# Initiates run of the remote command and yields the data when
|
|
280
297
|
# available. The yielding doesn't happen automatically, but as
|
|
281
298
|
# part of calling the `refresh` method.
|
|
282
299
|
def run_async(command)
|
|
283
|
-
raise 'Async command already in progress' if @started
|
|
300
|
+
raise 'Async command already in progress' if @process_manager&.started?
|
|
284
301
|
|
|
285
|
-
@started = false
|
|
286
302
|
@user_method.reset
|
|
287
|
-
|
|
288
|
-
@started = true
|
|
303
|
+
initialize_command(*get_args(command, true, quiet: true))
|
|
289
304
|
|
|
290
|
-
|
|
305
|
+
true
|
|
291
306
|
end
|
|
292
307
|
|
|
293
308
|
def run_started?
|
|
294
|
-
@started && @user_method.sent_all_data?
|
|
309
|
+
@process_manager&.started? && @user_method.sent_all_data?
|
|
295
310
|
end
|
|
296
311
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
312
|
+
def run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil)
|
|
313
|
+
pm = Proxy::Dynflow::ProcessManager.new(get_args(command, tty))
|
|
314
|
+
callback = proc do |data|
|
|
315
|
+
data.each_line do |line|
|
|
316
|
+
logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
|
|
317
|
+
user_method.on_data(data, pm.stdin) if user_method
|
|
318
|
+
end
|
|
319
|
+
''
|
|
304
320
|
end
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
318
|
-
return exit_status, stdout, stderr
|
|
321
|
+
pm.on_stdout(&callback)
|
|
322
|
+
pm.on_stderr(&callback)
|
|
323
|
+
pm.start!
|
|
324
|
+
unless pm.status
|
|
325
|
+
pm.stdin.io.puts(stdin) if stdin
|
|
326
|
+
pm.stdin.io.close if close_stdin
|
|
327
|
+
pm.run!
|
|
328
|
+
end
|
|
329
|
+
pm
|
|
319
330
|
end
|
|
320
331
|
|
|
321
332
|
def prepare_known_hosts
|
|
@@ -334,6 +345,10 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
334
345
|
File.join(ensure_local_directory(local_command_dir), filename)
|
|
335
346
|
end
|
|
336
347
|
|
|
348
|
+
def socket_file
|
|
349
|
+
File.join(ensure_local_directory(@socket_working_dir), @id)
|
|
350
|
+
end
|
|
351
|
+
|
|
337
352
|
def remote_command_dir
|
|
338
353
|
File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
|
|
339
354
|
end
|
|
@@ -362,15 +377,13 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
362
377
|
# We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
|
|
363
378
|
# This is used to write to $path with elevated permissions, solutions using cat and output redirection
|
|
364
379
|
# would not work, because the redirection would happen in the non-elevated shell.
|
|
365
|
-
command = "tee
|
|
380
|
+
command = "tee #{path} >/dev/null && chmod #{permissions} #{path}"
|
|
366
381
|
|
|
367
382
|
@logger.debug("Sending data to #{path} on remote host:\n#{data}")
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
|
|
373
|
-
end
|
|
383
|
+
ensure_remote_command(command,
|
|
384
|
+
stdin: data,
|
|
385
|
+
error: "Unable to upload file to #{path} on remote system, exit code: %{exit_code}"
|
|
386
|
+
)
|
|
374
387
|
|
|
375
388
|
path
|
|
376
389
|
end
|
|
@@ -382,9 +395,15 @@ module Proxy::RemoteExecution::Ssh::Runners
|
|
|
382
395
|
end
|
|
383
396
|
|
|
384
397
|
def ensure_remote_directory(path)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
398
|
+
ensure_remote_command("mkdir -p #{path}",
|
|
399
|
+
error: "Unable to create directory #{path} on remote system, exit code: %{exit_code}"
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def ensure_remote_command(cmd, error: nil, **kwargs)
|
|
404
|
+
if (pm = run_sync(cmd, **kwargs)).status != 0
|
|
405
|
+
msg = error || 'Failed to run command %{command} on remote machine, exit code: %{exit_code}'
|
|
406
|
+
raise(msg % { command: cmd, exit_code: pm.status })
|
|
388
407
|
end
|
|
389
408
|
end
|
|
390
409
|
|
|
@@ -7,22 +7,10 @@ module Proxy::RemoteExecution
|
|
|
7
7
|
module Ssh
|
|
8
8
|
class << self
|
|
9
9
|
def validate!
|
|
10
|
-
unless private_key_file
|
|
11
|
-
raise "settings for `ssh_identity_key` not set"
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
unless File.exist?(private_key_file)
|
|
15
|
-
raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
|
|
16
|
-
"You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
unless File.exist?(public_key_file)
|
|
20
|
-
raise "Ssh public key file #{public_key_file} doesn't exist"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
10
|
validate_mode!
|
|
24
|
-
|
|
11
|
+
validate_ssh_settings!
|
|
25
12
|
validate_mqtt_settings!
|
|
13
|
+
validate_socket_path!
|
|
26
14
|
end
|
|
27
15
|
|
|
28
16
|
def private_key_file
|
|
@@ -60,6 +48,28 @@ module Proxy::RemoteExecution
|
|
|
60
48
|
|
|
61
49
|
raise 'mqtt_broker has to be set when pull-mqtt mode is used' if Plugin.settings.mqtt_broker.nil?
|
|
62
50
|
raise 'mqtt_port has to be set when pull-mqtt mode is used' if Plugin.settings.mqtt_port.nil?
|
|
51
|
+
|
|
52
|
+
if Plugin.settings.mqtt_tls.nil?
|
|
53
|
+
Plugin.settings.mqtt_tls = [[:foreman_ssl_cert, :ssl_certificate], [:foreman_ssl_key, :ssl_private_key], [:foreman_ssl_ca, :ssl_ca_file]].all? { |(client, server)| ::Proxy::SETTINGS[client] || ::Proxy::SETTINGS[server] }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_ssh_settings!
|
|
58
|
+
return unless requires_configured_ssh?
|
|
59
|
+
unless private_key_file
|
|
60
|
+
raise "settings for `ssh_identity_key` not set"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless File.exist?(private_key_file)
|
|
64
|
+
raise "SSH public key file #{private_key_file} doesn't exist.\n"\
|
|
65
|
+
"You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless File.exist?(public_key_file)
|
|
69
|
+
raise "SSH public key file #{public_key_file} doesn't exist"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
validate_ssh_log_level!
|
|
63
73
|
end
|
|
64
74
|
|
|
65
75
|
def validate_ssh_log_level!
|
|
@@ -83,6 +93,17 @@ module Proxy::RemoteExecution
|
|
|
83
93
|
Plugin.settings.ssh_log_level = Plugin.settings.ssh_log_level.to_sym
|
|
84
94
|
end
|
|
85
95
|
|
|
96
|
+
def requires_configured_ssh?
|
|
97
|
+
%i[ssh ssh-async].include?(Plugin.settings.mode) || Plugin.settings.cockpit_integration
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_socket_path!
|
|
101
|
+
return unless Plugin.settings.mode == :'ssh' || Plugin.settings.mode == :'ssh-async'
|
|
102
|
+
|
|
103
|
+
socket_path = File.expand_path(Plugin.settings.socket_working_dir)
|
|
104
|
+
raise "Socket path #{socket_path} is too long" if socket_path.length > Plugin::SOCKET_PATH_MAX_LENGTH
|
|
105
|
+
end
|
|
106
|
+
|
|
86
107
|
def job_storage
|
|
87
108
|
@job_storage ||= Proxy::RemoteExecution::Ssh::JobStorage.new
|
|
88
109
|
end
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
:ssh_identity_key_file: '~/.ssh/id_rsa_foreman_proxy'
|
|
4
4
|
:local_working_dir: '/var/tmp'
|
|
5
5
|
:remote_working_dir: '/var/tmp'
|
|
6
|
+
:socket_working_dir: '/var/tmp'
|
|
6
7
|
# :kerberos_auth: false
|
|
7
8
|
|
|
9
|
+
# :cockpit_integration: true
|
|
10
|
+
|
|
8
11
|
# Mode of operation, one of ssh, ssh-async, pull, pull-mqtt
|
|
9
12
|
:mode: ssh
|
|
10
13
|
|
|
@@ -24,3 +27,8 @@
|
|
|
24
27
|
# MQTT configuration, need to be set if mode is set to pull-mqtt
|
|
25
28
|
# :mqtt_broker: localhost
|
|
26
29
|
# :mqtt_port: 1883
|
|
30
|
+
|
|
31
|
+
# Use of SSL can be forced either way by explicitly setting mqtt_tls setting. If
|
|
32
|
+
# unset, SSL gets used if smart-proxy's foreman_ssl_cert, foreman_ssl_key and
|
|
33
|
+
# foreman_ssl_ca settings are set available.
|
|
34
|
+
# :mqtt_tls:
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: smart_proxy_remote_execution_ssh
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Nečas
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -114,14 +114,14 @@ dependencies:
|
|
|
114
114
|
requirements:
|
|
115
115
|
- - "~>"
|
|
116
116
|
- !ruby/object:Gem::Version
|
|
117
|
-
version: '0.
|
|
117
|
+
version: '0.8'
|
|
118
118
|
type: :runtime
|
|
119
119
|
prerelease: false
|
|
120
120
|
version_requirements: !ruby/object:Gem::Requirement
|
|
121
121
|
requirements:
|
|
122
122
|
- - "~>"
|
|
123
123
|
- !ruby/object:Gem::Version
|
|
124
|
-
version: '0.
|
|
124
|
+
version: '0.8'
|
|
125
125
|
- !ruby/object:Gem::Dependency
|
|
126
126
|
name: net-ssh
|
|
127
127
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -203,7 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
203
203
|
- !ruby/object:Gem::Version
|
|
204
204
|
version: '0'
|
|
205
205
|
requirements: []
|
|
206
|
-
rubygems_version: 3.
|
|
206
|
+
rubygems_version: 3.2.26
|
|
207
207
|
signing_key:
|
|
208
208
|
specification_version: 4
|
|
209
209
|
summary: Ssh remote execution provider for Foreman Smart-Proxy
|