Skip to content

Commit

Permalink
Fixes #21731 - sudo can be used with passwords now
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitri-d authored and iNecas committed May 4, 2018
1 parent 99ec9d9 commit e65c15a
Show file tree
Hide file tree
Showing 16 changed files with 168 additions and 33 deletions.
3 changes: 2 additions & 1 deletion app/lib/actions/remote_execution/run_host_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ def plan(job_invocation, host, template_invocation, proxy_selector = ::RemoteExe
provider = template_invocation.template.provider

secrets = { :ssh_password => job_invocation.password || provider.ssh_password(host),
:key_passphrase => job_invocation.key_passphrase || provider.ssh_key_passphrase(host) }
:key_passphrase => job_invocation.key_passphrase || provider.ssh_key_passphrase(host),
:sudo_password => job_invocation.sudo_password || provider.sudo_password(host) }

additional_options = { :hostname => provider.find_ip_or_hostname(host),
:script => script,
Expand Down
2 changes: 1 addition & 1 deletion app/lib/actions/remote_execution/run_hosts_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def create_sub_plans
end

def finalize
job_invocation.password = job_invocation.key_passphrase = nil
job_invocation.password = job_invocation.key_passphrase = job_invocation.sudo_password = nil
job_invocation.save!

# creating the success notification should be the very last thing this tasks do
Expand Down
3 changes: 2 additions & 1 deletion app/models/job_invocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class JobInvocation < ApplicationRecord

delegate :start_at, :to => :task, :allow_nil => true

encrypts :password, :key_passphrase
encrypts :password, :key_passphrase, :sudo_password

def self.search_by_status(key, operator, value)
conditions = HostStatus::ExecutionStatus::ExecutionTaskStatusMapper.sql_conditions_for(value)
Expand Down Expand Up @@ -137,6 +137,7 @@ def deep_clone
invocation.pattern_template_invocations = self.pattern_template_invocations.map(&:deep_clone)
invocation.password = self.password
invocation.key_passphrase = self.key_passphrase
invocation.sudo_password = self.sudo_password
end
end

Expand Down
2 changes: 2 additions & 0 deletions app/models/job_invocation_composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def params
:description_format => job_invocation_base[:description_format],
:password => blank_to_nil(job_invocation_base[:password]),
:key_passphrase => blank_to_nil(job_invocation_base[:key_passphrase]),
:sudo_password => blank_to_nil(job_invocation_base[:sudo_password]),
:concurrency_control => concurrency_control_params,
:execution_timeout_interval => execution_timeout_interval,
:template_invocations => template_invocations_params }.with_indifferent_access
Expand Down Expand Up @@ -335,6 +336,7 @@ def compose
job_invocation.execution_timeout_interval = params[:execution_timeout_interval]
job_invocation.password = params[:password]
job_invocation.key_passphrase = params[:key_passphrase]
job_invocation.sudo_password = params[:sudo_password]

self
end
Expand Down
4 changes: 4 additions & 0 deletions app/models/remote_execution_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ def cleanup_working_dirs?(host)
[true, 'true', 'True', 'TRUE', '1'].include?(setting)
end

def sudo_password(host)
host_setting(host, :remote_execution_sudo_password)
end

def effective_interfaces(host)
interfaces = []
%w(execution primary provision).map do |flag|
Expand Down
3 changes: 2 additions & 1 deletion app/models/setting/remote_execution.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class Setting::RemoteExecution < Setting

::Setting::BLANK_ATTRS.concat %w{remote_execution_ssh_password remote_execution_ssh_key_passphrase}
::Setting::BLANK_ATTRS.concat %w{remote_execution_ssh_password remote_execution_ssh_key_passphrase remote_execution_sudo_password}

# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
def self.load_defaults
Expand Down Expand Up @@ -39,6 +39,7 @@ def self.load_defaults
N_('Effective User Method'),
nil,
{ :collection => proc { Hash[SSHExecutionProvider::EFFECTIVE_USER_METHODS.map { |method| [method, method] }] } }),
self.set('remote_execution_sudo_password', N_("Sudo password"), '', N_("Sudo password"), nil, {:encrypted => true}),
self.set('remote_execution_sync_templates',
N_('Whether we should sync templates from disk when running db:seed.'),
true,
Expand Down
5 changes: 3 additions & 2 deletions app/models/ssh_execution_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ def proxy_command_options(template_invocation, host)
super.merge(:ssh_user => ssh_user(host),
:effective_user => effective_user(template_invocation),
:effective_user_method => effective_user_method(host),
:ssh_port => ssh_port(host),
:cleanup_working_dirs => cleanup_working_dirs?(host))
:cleanup_working_dirs => cleanup_working_dirs?(host),
:sudo_password => sudo_password(host),
:ssh_port => ssh_port(host))
end

def humanized_name
Expand Down
1 change: 1 addition & 0 deletions app/views/job_invocations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
<div class="advanced hidden">
<%= password_f f, :password, :placeholder => '*****', :label => _('Password'), :label_help => N_('Password is stored encrypted in DB until the job finishes. For future or recurring executions, it is removed after the last execution.') %>
<%= password_f f, :key_passphrase, :placeholder => '*****', :label => _('Private key passphrase'), :label_help => N_('Key passhprase is only applicable for SSH provider. Other providers ignore this field. <br> Passphrase is stored encrypted in DB until the job finishes. For future or recurring executions, it is removed after the last execution.') %>
<%= password_f f, :sudo_password, :placeholder => '*****', :label => _('Sudo password'), :label_help => N_('Sudo password is only applicable for SSH provider. Other providers ignore this field. <br> Password is stored encrypted in DB until the job finishes. For future or recurring executions, it is removed after the last execution.') %>
</div>

<div class="advanced hidden">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddSudoPasswordToJobInvocation < ActiveRecord::Migration[4.2]
def change
add_column :job_invocations, :sudo_password, :string
end
end
2 changes: 1 addition & 1 deletion lib/foreman_remote_execution_core/actions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def initiate_runner
:step_id => run_step_id,
:uuid => execution_plan_id
}
ForemanRemoteExecutionCore.runner_class.new(input.merge(additional_options))
ForemanRemoteExecutionCore.runner_class.build(input.merge(additional_options))
end

def runner_dispatcher
Expand Down
4 changes: 4 additions & 0 deletions lib/foreman_remote_execution_core/fake_script_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ def load_data(path = nil)
end
@data.freeze
end

def self.build(options)
new(options)
end
end

def initialize(*args)
Expand Down
10 changes: 5 additions & 5 deletions lib/foreman_remote_execution_core/polling_script_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ class PollingScriptRunner < ScriptRunner

DEFAULT_REFRESH_INTERVAL = 60

def initialize(options = {})
super(options)
def initialize(options, user_method)
super(options, user_method)
@callback_host = options[:callback_host]
@task_id = options[:uuid]
@step_id = options[:step_id]
Expand All @@ -24,7 +24,7 @@ def control_script
close_fds = close_stdin + ' >/dev/null 2>/dev/null'
# pipe the output to tee while capturing the exit code in a file, don't wait for it to finish, output PID of the main command
<<-SCRIPT.gsub(/^\s+\| /, '')
| sh -c '(#{su_prefix}#{@remote_script} #{close_stdin}; echo $?>#{@exit_code_path}) | /usr/bin/tee #{@output_path} >/dev/null; #{callback_scriptlet}' #{close_fds} &
| sh -c '(#{@user_method.cli_command_prefix}#{@remote_script} #{close_stdin}; echo $?>#{@exit_code_path}) | /usr/bin/tee #{@output_path} >/dev/null; #{callback_scriptlet}' #{close_fds} &
| echo $! > '#{@pid_path}'
SCRIPT
end
Expand All @@ -36,7 +36,7 @@ def trigger(*args)
def refresh
err = output = nil
with_retries do
_, output, err = run_sync("#{su_prefix} #{@retrieval_script}")
_, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
end
lines = output.lines
result = lines.shift.match(/^DONE (\d+)?/)
Expand Down Expand Up @@ -92,7 +92,7 @@ def prepare_retrieval
def callback_scriptlet(callback_script_path = nil)
if @otp
callback_script_path = cp_script_to_remote(callback_script, 'callback') if callback_script_path.nil?
"#{su_prefix}#{callback_script_path}"
"#{@user_method.cli_command_prefix}#{callback_script_path}"
else
':' # Shell synonym for "do nothing"
end
Expand Down
136 changes: 115 additions & 21 deletions lib/foreman_remote_execution_core/script_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,98 @@
# rubocop:enable Lint/HandleExceptions:

module ForemanRemoteExecutionCore
class SudoUserMethod
LOGIN_PROMPT = 'rex login: '.freeze

attr_reader :effective_user, :ssh_user, :effective_user_password, :password_sent

def initialize(effective_user, ssh_user, effective_user_password)
@effective_user = effective_user
@ssh_user = ssh_user
@effective_user_password = effective_user_password.to_s
@password_sent = false
end

def on_data(received_data, ssh_channel)
if received_data.match(LOGIN_PROMPT)
ssh_channel.send_data(effective_user_password + "\n")
@password_sent = true
end
end

def filter_password?(received_data)
!@effective_user_password.empty? && @password_sent && received_data.match(@effective_user_password)
end

def sent_all_data?
effective_user_password.empty? || password_sent
end

def cli_command_prefix
"sudo -p '#{LOGIN_PROMPT}' -u #{effective_user} "
end

def reset
@password_sent = false
end
end

class SuUserMethod
attr_accessor :effective_user, :ssh_user

def initialize(effective_user, ssh_user)
@effective_user = effective_user
@ssh_user = ssh_user
end

def on_data(_, _); end

def filter_password?(received_data)
false
end

def sent_all_data?
true
end

def cli_command_prefix
"su - #{effective_user} -c "
end

def reset; end
end

class NoopUserMethod
def on_data(_, _); end

def filter_password?(received_data)
false
end

def sent_all_data?
true
end

def cli_command_prefix; end

def reset; end
end

class ScriptRunner < ForemanTasksCore::Runner::Base
attr_reader :execution_timeout_interval

EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
DEFAULT_REFRESH_INTERVAL = 1
MAX_PROCESS_RETRIES = 4

def initialize(options)
def initialize(options, user_method)
super()
@host = options.fetch(:hostname)
@script = options.fetch(:script)
@ssh_user = options.fetch(:ssh_user, 'root')
@ssh_port = options.fetch(:ssh_port, 22)
@ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
@key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
@effective_user = options.fetch(:effective_user, nil)
@effective_user_method = options.fetch(:effective_user_method, 'sudo')
@host_public_key = options.fetch(:host_public_key, nil)
@verify_host = options.fetch(:verify_host, nil)
@execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
Expand All @@ -33,6 +108,26 @@ def initialize(options)
@local_working_dir = options.fetch(:local_working_dir, settings.fetch(:local_working_dir))
@remote_working_dir = options.fetch(:remote_working_dir, settings.fetch(:remote_working_dir))
@cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.fetch(:cleanup_working_dirs))
@user_method = user_method
end

def self.build(options)
effective_user = options.fetch(:effective_user, nil)
ssh_user = options.fetch(:ssh_user, 'root')
effective_user_method = options.fetch(:effective_user_method, 'sudo')

user_method = if effective_user.nil? || effective_user == ssh_user
NoopUserMethod.new
elsif effective_user_method == 'sudo'
SudoUserMethod.new(effective_user, ssh_user,
options.fetch(:secrets, {}).fetch(:sudo_password, nil))
elsif effective_user_method == 'su'
SuUserMethod.new(effective_user, ssh_user)
else
raise "effective_user_method '#{effective_user_method}' not supported"
end

new(options, user_method)
end

def start
Expand All @@ -59,7 +154,7 @@ def control_script
# pipe the output to tee while capturing the exit code in a file
<<-SCRIPT.gsub(/^\s+\| /, '')
| sh <<WRAPPER
| (#{su_prefix}#{@remote_script} < /dev/null; echo \\$?>#{@exit_code_path}) | /usr/bin/tee #{@output_path}
| (#{@user_method.cli_command_prefix}#{@remote_script} < /dev/null; echo \\$?>#{@exit_code_path}) | /usr/bin/tee #{@output_path}
| exit \\$(cat #{@exit_code_path})
| WRAPPER
SCRIPT
Expand Down Expand Up @@ -178,9 +273,14 @@ def settings
def run_async(command)
raise 'Async command already in progress' if @started
@started = false
@user_method.reset

session.open_channel do |channel|
channel.request_pty
channel.on_data { |ch, data| publish_data(data, 'stdout') }
channel.on_data do |ch, data|
publish_data(data, 'stdout') unless @user_method.filter_password?(data)
@user_method.on_data(data, ch)
end
channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
# standard exit of the command
channel.on_request('exit-status') { |ch, data| publish_exit_status(data.read_long) }
Expand All @@ -192,23 +292,29 @@ def run_async(command)
# that the session is inactive
ch.wait
end
channel.exec(command) do |ch, success|
channel.exec(command) do |_, success|
@started = true
raise('Error initializing command') unless success
end
end
session.process(0) until @started
session.loop(0.1) { !run_started? }
return true
end

def run_started?
@started && @user_method.sent_all_data?
end

def run_sync(command, stdin = nil)
stdout = ''
stderr = ''
exit_status = nil
started = false

channel = session.open_channel do |ch|
ch.on_data { |_, data| stdout.concat(data) }
ch.on_data do |c, data|
stdout.concat(data)
end
ch.on_extended_data { |_, _, data| stderr.concat(data) }
ch.on_request('exit-status') { |_, data| exit_status = data.read_long }
# Send data to stdin if we have some
Expand All @@ -224,25 +330,13 @@ def run_sync(command, stdin = nil)
started = true
end
end
session.process(0) until started
session.loop(0.1) { !started }
# Closing the channel without sending any data gives us SIGPIPE
channel.close unless stdin.nil?
channel.wait
return exit_status, stdout, stderr
end

def su_prefix
return if @effective_user.nil? || @effective_user == @ssh_user
case @effective_user_method
when 'sudo'
"sudo -n -u #{@effective_user} "
when 'su'
"su - #{@effective_user} -c "
else
raise "effective_user_method ''#{@effective_user_method}'' not supported"
end
end

def prepare_known_hosts
path = local_command_file('known_hosts')
if @host_public_key
Expand Down
1 change: 1 addition & 0 deletions test/unit/actions/run_hosts_job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class RunHostsJobTest < ActiveSupport::TestCase
invocation.description = 'Some short description'
invocation.password = 'changeme'
invocation.key_passphrase = 'changemetoo'
invocation.sudo_password = 'sudopassword'
invocation.save
end
end
Expand Down

0 comments on commit e65c15a

Please sign in to comment.