foreman_remote_execution 1.7.1 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
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