Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #21731 - sudo can be used with passwords now #342

Merged
merged 1 commit into from May 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/lib/actions/remote_execution/run_host_job.rb
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
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
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
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
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
@@ -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
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
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
@@ -0,0 +1,5 @@
class AddSudoPasswordToJobInvocation < ActiveRecord::Migration[4.2]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/FrozenStringLiteralComment: Missing magic comment # frozen_string_literal: true.

def change
add_column :job_invocations, :sudo_password, :string
end
end
2 changes: 1 addition & 1 deletion lib/foreman_remote_execution_core/actions.rb
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
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
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
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
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