foreman_remote_execution 1.7.1 → 1.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d41d1cca071fdfacdc4d90bccc1ad8fe601cd11dfbe7fa24336b162f814820e2
4
- data.tar.gz: 396b3d87bb864f6697dc641efd0b242af8b52148d339d33a05ce47ddf84a107f
3
+ metadata.gz: 4869e54ee272ae8778eadddd9c3f6304269d924c9d44c42a5f9253ab611eae3a
4
+ data.tar.gz: ee709617e65b2cb1490c4881517083c48baa138b680ad2afd080936b6c06f19a
5
5
  SHA512:
6
- metadata.gz: 70c95de572058c181a0eca7c3bd63b36844bcccc7f6e494ed5670713d3b232c2135547b3ca8fb85252bea16f1a055822dd988dcb8d109b193ceef8dda4886f2f
7
- data.tar.gz: 722424c91de10100b3f47c3f21a4a3e18aebf1cad5916a98c0d2f92a420cb372149586e8e034b39f325197be4687f56ee8bab3c7a444f1a305301aa522b9761f
6
+ metadata.gz: f57be80e767108908fdd729bd05c640f541ef027d97d571a5ce9377d46a93fa9d32f0e326106f770086cd8d394efbfaa3790c486f1d90b34a437ec4a77b08331
7
+ data.tar.gz: cf423d9c36a3469e627644256f1e0da661cb4ea69393eae8119b15af347b5500fdafb939a94fb9b4ca326359d8578b985b136b7320ea5c367f45f91cea903224
@@ -0,0 +1,34 @@
1
+ class CockpitController < ApplicationController
2
+ before_action :find_resource, :only => [:host_ssh_params]
3
+
4
+ def host_ssh_params
5
+ render :json => SSHExecutionProvider.ssh_params(@host)
6
+ end
7
+
8
+ def redirect
9
+ return invalid_request unless params[:redirect_uri]
10
+ redir_url = URI.parse(params[:redirect_uri])
11
+
12
+ cockpit_url = SSHExecutionProvider.cockpit_url_for_host('')
13
+ redir_url.query = if redir_url.hostname == URI.join(Setting[:foreman_url], cockpit_url).hostname
14
+ "access_token=#{request.session_options[:id]}"
15
+ else
16
+ "error_description=Sorry"
17
+ end
18
+ redirect_to(redir_url.to_s)
19
+ end
20
+
21
+ private
22
+
23
+ def resource_name
24
+ "host"
25
+ end
26
+
27
+ def controller_permission
28
+ :hosts
29
+ end
30
+
31
+ def action_permission
32
+ :cockpit
33
+ end
34
+ end
@@ -20,9 +20,17 @@ module ForemanRemoteExecution
20
20
  link_to(_('Schedule Remote Job'), new_job_invocation_path(:host_ids => [args.first.id]), :id => :run_button, :class => 'btn btn-default')
21
21
  end
22
22
 
23
+ def web_console_button(host, *args)
24
+ return unless authorized_for(permission: 'cockpit_hosts', auth_object: host)
25
+ url = SSHExecutionProvider.cockpit_url_for_host(host.name)
26
+ url ? link_to(_('Web Console'), url, :class => 'btn btn-default') : nil
27
+ end
28
+
23
29
  def host_title_actions(*args)
24
- title_actions(button_group(schedule_job_multi_button(*args)))
30
+ title_actions(button_group(schedule_job_multi_button(*args)),
31
+ button_group(web_console_button(*args)))
25
32
  super(*args)
26
33
  end
27
34
  end
35
+
28
36
  end
@@ -1,6 +1,6 @@
1
1
  class Setting::RemoteExecution < Setting
2
2
 
3
- ::Setting::BLANK_ATTRS.concat %w{remote_execution_ssh_password remote_execution_ssh_key_passphrase remote_execution_sudo_password}
3
+ ::Setting::BLANK_ATTRS.concat %w{remote_execution_ssh_password remote_execution_ssh_key_passphrase remote_execution_sudo_password remote_execution_cockpit_url}
4
4
 
5
5
  # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
6
6
  def self.load_defaults
@@ -67,7 +67,12 @@ class Setting::RemoteExecution < Setting
67
67
  self.set('remote_execution_cleanup_working_dirs',
68
68
  N_('When enabled, working directories will be removed after task completion. You may override this per host by setting a parameter called remote_execution_cleanup_working_dirs.'),
69
69
  true,
70
- N_('Cleanup working directories'))
70
+ N_('Cleanup working directories')),
71
+ self.set('remote_execution_cockpit_url',
72
+ N_('Where to find the Cockpit instance for the Web Console button. By default, no button is shown.'),
73
+ nil,
74
+ N_('Cockpit URL'),
75
+ nil)
71
76
  ].each { |s| self.create! s.update(:category => 'Setting::RemoteExecution') }
72
77
  end
73
78
 
@@ -29,6 +29,23 @@ class SSHExecutionProvider < RemoteExecutionProvider
29
29
  'ssh'
30
30
  end
31
31
 
32
+ def ssh_params(host)
33
+ proxy_selector = ::RemoteExecutionProxySelector.new
34
+ proxy = proxy_selector.determine_proxy(host, 'SSH')
35
+ {
36
+ :hostname => find_ip_or_hostname(host),
37
+ :proxy => proxy.class == Symbol ? proxy : proxy.url,
38
+ :ssh_user => ssh_user(host),
39
+ :ssh_port => ssh_port(host),
40
+ :ssh_password => ssh_password(host),
41
+ :ssh_key_passphrase => ssh_key_passphrase(host)
42
+ }
43
+ end
44
+
45
+ def cockpit_url_for_host(host)
46
+ Setting[:remote_execution_cockpit_url] % { :host => host } if Setting[:remote_execution_cockpit_url].present?
47
+ end
48
+
32
49
  private
33
50
 
34
51
  def ssh_user(host)
@@ -1,4 +1,3 @@
1
- <%= javascript 'lookup_keys' %>
2
1
  <%= javascript 'foreman_remote_execution/template_input' %>
3
2
 
4
3
  <%= breadcrumbs(
@@ -1,5 +1,4 @@
1
1
  <%= javascript 'foreman_remote_execution/job_templates' %>
2
- <%= javascript 'lookup_keys' %>
3
2
  <%= javascript 'foreman_remote_execution/template_input' %>
4
3
 
5
4
  <% title _("Job Templates") %>
@@ -1,4 +1,3 @@
1
- <%= javascript 'lookup_keys' %>
2
1
  <%= javascript 'foreman_remote_execution/template_input' %>
3
2
 
4
3
  <% title _("New Job Template") %>
data/config/routes.rb CHANGED
@@ -38,6 +38,11 @@ Rails.application.routes.draw do
38
38
  end
39
39
  end
40
40
 
41
+ constraints(:id => %r{[^/]+}) do
42
+ get 'cockpit/host_ssh_params/:id', to: 'cockpit#host_ssh_params'
43
+ end
44
+ get 'cockpit/redirect', to: 'cockpit#redirect'
45
+
41
46
  namespace :api, :defaults => {:format => 'json'} do
42
47
  scope '(:apiv)', :module => :v2, :defaults => {:apiv => 'v2'}, :apiv => /v1|v2/, :constraints => ApiConstraints.new(:version => 2, :default => true) do
43
48
  resources :job_invocations, :except => [:new, :edit, :update, :destroy] do
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "net/https"
6
+ require "yaml"
7
+
8
+ # Logging
9
+
10
+ LOG = Logger.new($stderr)
11
+ LOG.formatter = proc { | severity, datetime, progname, msg | "#{severity}: #{msg}\n" }
12
+
13
+ def safe_log(format_string, data = nil)
14
+ if data.is_a? Hash
15
+ data = data.dup
16
+ data.each do |key, _|
17
+ if key.to_s =~ /password|passphrase/
18
+ data[key] = '*******'
19
+ end
20
+ end
21
+ end
22
+ format_string % [data]
23
+ end
24
+
25
+ # Settings
26
+
27
+ def read_settings
28
+ settings_path = ENV["FOREMAN_COCKPIT_SETTINGS"] || "/etc/foreman-cockpit/settings.yml"
29
+ settings = YAML.load(File.read(settings_path))
30
+ LOG.level = Logger.const_get(settings.fetch(:log_level, "INFO"))
31
+ LOG.info("Running foreman-cockpit-session with settings from #{settings_path}:\n#{settings.inspect}")
32
+ settings
33
+ end
34
+
35
+ # Cockpit protocol, encoding and decoding of control messages.
36
+
37
+ def send_control(msg)
38
+ text = JSON.dump(msg)
39
+ LOG.debug("Sending control message #{text}")
40
+ $stdout.write("#{text.length+1}\n\n#{text}")
41
+ $stdout.flush
42
+ end
43
+
44
+ def read_control
45
+ size = $stdin.readline.chomp.to_i
46
+ raise ArgumentError, "Invalid frame: invalid size" if size.zero?
47
+ data = $stdin.read(size)
48
+ LOG.debug("Received control message #{data.lstrip}")
49
+ raise ArgumentError, "Invalid frame: too short" if data == nil || data.length < size
50
+ JSON.parse(data)
51
+ end
52
+
53
+ # Specific control messages
54
+
55
+ def send_auth_challenge(challenge)
56
+ send_control({ "command" => "authorize",
57
+ "cookie" => "1234", # must be present, but value doesn't matter
58
+ "challenge" => challenge
59
+ })
60
+ end
61
+
62
+ def send_auth_response(response)
63
+ send_control({ "command" => "authorize",
64
+ "response" => response
65
+ })
66
+ end
67
+
68
+ def read_auth_reply
69
+ cmd = read_control()
70
+ response = cmd["response"]
71
+ raise ArgumentError, "Did not receive a valid authorize command" if cmd["command"] != "authorize" || !response
72
+ response
73
+ end
74
+
75
+ def exit_with_problem(problem, message, auth_methods)
76
+ LOG.error("#{problem} - #{message}")
77
+ send_control({ "command" => "init",
78
+ "problem" => problem,
79
+ "message" => message,
80
+ "auth-method-results" => auth_methods
81
+ })
82
+ exit 1
83
+ end
84
+
85
+ # Talking to Foreman
86
+
87
+ def get_token_from_auth_data(auth_data)
88
+ auth_data.split(" ")[1]
89
+ end
90
+
91
+ def foreman_call(path, token)
92
+ foreman = SETTINGS[:foreman_url] || "https://localhost/"
93
+ uri = URI(foreman + "/" + path)
94
+
95
+ LOG.debug("Foreman request GET #{uri}")
96
+
97
+ http = Net::HTTP.new(uri.hostname, uri.port)
98
+ if uri.scheme == "https"
99
+ http.use_ssl = true
100
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
101
+ http.ca_file = SETTINGS[:ssl_ca_file]
102
+ end
103
+
104
+ req = Net::HTTP::Get.new(uri)
105
+ req["Cookie"] = "_session_id=#{token}"
106
+ res = http.request(req)
107
+
108
+ LOG.debug do
109
+ body = JSON.parse(res.body) rescue res.body
110
+ safe_log("Foreman response #{res.code} - %s", body)
111
+ end
112
+
113
+ case res.code
114
+ when "200"
115
+ return JSON.parse(res.body)
116
+ when "401"
117
+ exit_with_problem("authentication-failed",
118
+ "Token was not valid",
119
+ { "password" => "not-tried", "token" => "denied" })
120
+ when "404"
121
+ return nil
122
+ else
123
+ LOG.error("Error talking to foreman: #{res.body}\n")
124
+ exit 1
125
+ end
126
+ end
127
+
128
+ # SSH via the smart proxy
129
+
130
+ def ssh_write_request_header(url, sock, params)
131
+ data = JSON.dump(params) + "\r\n"
132
+ sock.write("POST /ssh/session HTTP/1.1\r\nHost: #{url.host}:#{url.port}\r\nConnection: upgrade\r\nUpgrade: raw\r\nContent-Length: #{data.length}\r\n\r\n#{data}")
133
+ sock.flush
134
+ end
135
+
136
+ def ssh_read_and_handle_response_header(sock, url, params)
137
+ header = ""
138
+ loop do
139
+ line = sock.readline
140
+ break unless line and line != "\r\n"
141
+ header += line
142
+ end
143
+
144
+ status_line, headers_text = header.split("\r\n", 2)
145
+ status = status_line.split(" ")[1]
146
+ if status != "101"
147
+ m = /^Content-Length:[ \t]*([0-9]+)\r?$/i.match(headers_text)
148
+ if m
149
+ expected_len = m[1].to_i
150
+ else
151
+ expected_len = -1
152
+ end
153
+ response = ""
154
+ while expected_len < 0 || response.length < expected_len
155
+ begin
156
+ response += sock.readpartial(4096)
157
+ rescue EOFError
158
+ break
159
+ end
160
+ end
161
+ if status == "404"
162
+ exit_with_problem("access-denied", "The proxy #{url.hostname} does not support web console sessions", nil)
163
+ elsif status[0] == "4"
164
+ if response.include? "cockpit-bridge: command not found"
165
+ exit_with_problem("access-denied", "#{params["hostname"]} has no web console", nil)
166
+ else
167
+ exit_with_problem("access-denied", response, nil)
168
+ end
169
+ else
170
+ LOG.error("Error talking to smart proxy: #{response}\n")
171
+ exit 1
172
+ end
173
+ end
174
+ end
175
+
176
+ def ssh_read_sock(sock)
177
+ data = ""
178
+ begin
179
+ loop do
180
+ data += sock.read_nonblock(4096)
181
+ end
182
+ rescue IO::WaitReadable
183
+ data
184
+ rescue IO::WaitWritable
185
+ # This might happen with SSL during a renegotiation. Block a
186
+ # bit to get it over with.
187
+ IO.select(nil, [sock])
188
+ retry
189
+ end
190
+ data
191
+ end
192
+
193
+ def ssh_write_sock(sock, data)
194
+ begin
195
+ sock.write_nonblock(data)
196
+ rescue IO::WaitWritable
197
+ 0
198
+ rescue IO::WaitReadable
199
+ # This might happen with SSL during a renegotiation. Block a
200
+ # bit to get it over with.
201
+ IO.select([sock])
202
+ retry
203
+ end
204
+ end
205
+
206
+ def ssh_with_proxy(proxy, params)
207
+ url = URI(proxy)
208
+ LOG.debug("Connecting to proxy at #{url}")
209
+ raw_sock = TCPSocket.open(url.hostname, url.port)
210
+ if url.scheme == 'https'
211
+ ssl_context = OpenSSL::SSL::SSLContext.new()
212
+ ssl_context.cert = OpenSSL::X509::Certificate.new(File.read(SETTINGS[:ssl_certificate]))
213
+ ssl_context.key = OpenSSL::PKey.read(File.read(SETTINGS[:ssl_private_key]))
214
+ sock = OpenSSL::SSL::SSLSocket.new(raw_sock, ssl_context)
215
+ sock.sync_close = true
216
+ sock.connect
217
+ else
218
+ sock = raw_sock
219
+ end
220
+
221
+ ssh_write_request_header(url, sock, params)
222
+ ssh_read_and_handle_response_header(sock, url, params)
223
+
224
+ inp_buf = ""
225
+ out_buf = ssh_read_sock(sock)
226
+
227
+ ws_eof = false
228
+ bridge_eof = false
229
+
230
+ loop do
231
+ readers = [ ]
232
+ writers = [ ]
233
+
234
+ readers += [ $stdin ] unless ws_eof
235
+ readers += [ sock ] unless bridge_eof
236
+ writers += [ $stdout ] unless out_buf == ""
237
+ writers += [ sock ] unless inp_buf == ""
238
+
239
+ break if readers.length + writers.length == 0
240
+
241
+ r, w, x = IO.select(readers, writers)
242
+
243
+ if r.include?(sock)
244
+ begin
245
+ out_buf += ssh_read_sock(sock)
246
+ rescue EOFError
247
+ bridge_eof = true
248
+ break if out_buf == ""
249
+ end
250
+ end
251
+
252
+ if w.include?(sock)
253
+ begin
254
+ n = ssh_write_sock(sock, inp_buf)
255
+ inp_buf = inp_buf[n..-1]
256
+ raw_sock.close_write() if inp_buf == "" and ws_eof
257
+ end
258
+ end
259
+
260
+ if r.include?($stdin)
261
+ begin
262
+ inp_buf += $stdin.readpartial(4096)
263
+ rescue EOFError
264
+ ws_eof = true
265
+ raw_sock.close_write() if inp_buf == ""
266
+ end
267
+ end
268
+
269
+ if w.include?($stdout)
270
+ n = $stdout.write(out_buf)
271
+ $stdout.flush()
272
+ out_buf = out_buf[n..-1]
273
+ break if out_buf == "" and bridge_eof
274
+ end
275
+
276
+ end
277
+ end
278
+
279
+ # Main
280
+
281
+ SETTINGS = read_settings
282
+
283
+ host = ARGV[0]
284
+
285
+ send_auth_challenge("*")
286
+ token = get_token_from_auth_data(read_auth_reply())
287
+
288
+ params = foreman_call("cockpit/host_ssh_params/#{host}", token)
289
+ exit_with_problem("access-denied", "Host #{host} is not known", nil) unless params
290
+
291
+ LOG.debug(safe_log("SSH parameters %s", params))
292
+
293
+ params["command"] = "cockpit-bridge"
294
+ case params["proxy"]
295
+ when "not_available"
296
+ exit_with_problem("access-denied", "A proxy is required to reach #{host} but all of them are down", nil)
297
+ when "not_defined"
298
+ exit_with_problem("access-denied", "A proxy is required to reach #{host} but none has been configured", nil)
299
+ when "direct"
300
+ exit_with_problem("access-denied", "Web console sessions require a proxy but none has been configured", nil)
301
+ else
302
+ ssh_with_proxy(params["proxy"], params)
303
+ end
@@ -0,0 +1,8 @@
1
+ :foreman_url: http://localhost:3000/
2
+
3
+ # :log_level: INFO
4
+
5
+ # Certificates used to call to external proxies
6
+ # :ssl_ca_file: /path/to/certs/cacert.pem
7
+ # :ssl_certificate: /path/to/certs/cert.pem
8
+ # :ssl_private_key: /path/to/certs/key.pem
@@ -66,6 +66,7 @@ module ForemanRemoteExecution
66
66
  # this permissions grants user to get auto completion hints when setting up filters
67
67
  permission :filter_autocompletion_for_template_invocation, { :template_invocations => [ :auto_complete_search, :index ] },
68
68
  :resource_type => 'TemplateInvocation'
69
+ permission :cockpit_hosts, { 'cockpit' => [:redirect, :host_ssh_params] }, :resource_type => 'Host'
69
70
  end
70
71
 
71
72
  USER_PERMISSIONS = [
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '1.7.1'.freeze
2
+ VERSION = '1.8.0'.freeze
3
3
  end
@@ -0,0 +1,17 @@
1
+ require 'test_plugin_helper'
2
+
3
+ class CockpitControllerTest < ActionController::TestCase
4
+ def setup
5
+ Setting::RemoteExecution.load_defaults
6
+ as_admin do
7
+ @host = FactoryBot.create(:host)
8
+ end
9
+ end
10
+
11
+ test "should get host_ssh_params" do
12
+ get :host_ssh_params, params: { id: @host.id }, session: set_session_user
13
+ assert_response :success
14
+ response = ActiveSupport::JSON.decode(@response.body)
15
+ assert response.key?('ssh_user'), 'ssh_params response must include ssh_user'
16
+ end
17
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-04-11 00:00:00.000000000 Z
11
+ date: 2019-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: deface
@@ -155,6 +155,7 @@ files:
155
155
  - app/controllers/api/v2/job_templates_controller.rb
156
156
  - app/controllers/api/v2/remote_execution_features_controller.rb
157
157
  - app/controllers/api/v2/template_invocations_controller.rb
158
+ - app/controllers/cockpit_controller.rb
158
159
  - app/controllers/concerns/foreman/controller/parameters/foreign_input_set.rb
159
160
  - app/controllers/concerns/foreman/controller/parameters/job_template.rb
160
161
  - app/controllers/concerns/foreman/controller/parameters/remote_execution_feature.rb
@@ -329,6 +330,8 @@ files:
329
330
  - db/seeds.d/60-ssh_proxy_feature.rb
330
331
  - db/seeds.d/70-job_templates.rb
331
332
  - db/seeds.d/90-bookmarks.rb
333
+ - extra/cockpit/foreman-cockpit-session
334
+ - extra/cockpit/settings.yml.example
332
335
  - foreman_remote_execution.gemspec
333
336
  - lib/foreman_remote_execution.rb
334
337
  - lib/foreman_remote_execution/engine.rb
@@ -369,6 +372,7 @@ files:
369
372
  - test/functional/api/v2/job_templates_controller_test.rb
370
373
  - test/functional/api/v2/remote_execution_features_controller_test.rb
371
374
  - test/functional/api/v2/template_invocations_controller_test.rb
375
+ - test/functional/cockpit_controller_test.rb
372
376
  - test/functional/job_invocations_controller_test.rb
373
377
  - test/functional/job_templates_controller_test.rb
374
378
  - test/helpers/remote_execution_helper_test.rb
@@ -420,7 +424,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
420
424
  version: '0'
421
425
  requirements: []
422
426
  rubyforge_project:
423
- rubygems_version: 2.7.3
427
+ rubygems_version: 2.7.6
424
428
  signing_key:
425
429
  specification_version: 4
426
430
  summary: A plugin bringing remote execution to the Foreman, completing the config
@@ -434,6 +438,7 @@ test_files:
434
438
  - test/functional/api/v2/job_templates_controller_test.rb
435
439
  - test/functional/api/v2/remote_execution_features_controller_test.rb
436
440
  - test/functional/api/v2/template_invocations_controller_test.rb
441
+ - test/functional/cockpit_controller_test.rb
437
442
  - test/functional/job_invocations_controller_test.rb
438
443
  - test/functional/job_templates_controller_test.rb
439
444
  - test/helpers/remote_execution_helper_test.rb