bolt 2.4.0 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

@@ -1,216 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'open3'
4
- require 'fileutils'
5
- require 'bolt/node/output'
6
- require 'bolt/util'
7
-
8
- module Bolt
9
- module Transport
10
- class Local < Sudoable
11
- class Shell < Sudoable::Connection
12
- attr_accessor :user, :logger, :target
13
- attr_writer :run_as
14
-
15
- CHUNK_SIZE = 4096
16
-
17
- def initialize(target)
18
- @target = target
19
- # The familiar problem: Etc.getlogin is broken on osx
20
- @user = ENV['USER'] || Etc.getlogin
21
- @run_as = target.options['run-as']
22
- @logger = Logging.logger[self]
23
- @sudo_id = SecureRandom.uuid
24
- end
25
-
26
- # If prompted for sudo password, send password to stdin and return an
27
- # empty string. Otherwise, check for sudo errors and raise Bolt error.
28
- # If sudo_id is detected, that means the task needs to have stdin written.
29
- # If error is not sudo-related, return the stderr string to be added to
30
- # node output
31
- def handle_sudo(stdin, err, pid, sudo_stdin)
32
- if err.include?(Sudoable.sudo_prompt)
33
- # A wild sudo prompt has appeared!
34
- if @target.options['sudo-password']
35
- stdin.write("#{@target.options['sudo-password']}\n")
36
- ''
37
- else
38
- raise Bolt::Node::EscalateError.new(
39
- "Sudo password for user #{@user} was not provided for localhost",
40
- 'NO_PASSWORD'
41
- )
42
- end
43
- elsif err =~ /^#{@sudo_id}/
44
- if sudo_stdin
45
- stdin.write("#{sudo_stdin}\n")
46
- stdin.close
47
- end
48
- ''
49
- else
50
- handle_sudo_errors(err, pid)
51
- end
52
- end
53
-
54
- def handle_sudo_errors(err, pid)
55
- if err =~ /^#{@user} is not in the sudoers file\./
56
- @logger.debug { err }
57
- raise Bolt::Node::EscalateError.new(
58
- "User #{@user} does not have sudo permission on localhost",
59
- 'SUDO_DENIED'
60
- )
61
- elsif err =~ /^Sorry, try again\./
62
- @logger.debug { err }
63
- # CODEREVIEW can we kill a sudo process without sudo password?
64
- Process.kill('TERM', pid)
65
- raise Bolt::Node::EscalateError.new(
66
- "Sudo password for user #{@user} not recognized on localhost",
67
- 'BAD_PASSWORD'
68
- )
69
- else
70
- # No need to raise an error - just return the string
71
- err
72
- end
73
- end
74
-
75
- def copy_file(source, dest)
76
- @logger.debug { "Uploading #{source}, to #{dest}" }
77
- if source.is_a?(StringIO)
78
- File.open("tempfile", "w") { |f| f.write(source.read) }
79
- execute(['mv', 'tempfile', dest])
80
- else
81
- # Mimic the behavior of `cp --remove-destination`
82
- # since the flag isn't supported on MacOS
83
- result = execute(['rm', '-rf', dest])
84
- if result.exit_code != 0
85
- message = "Could not remove existing file #{dest}: #{result.stderr.string}"
86
- raise Bolt::Node::FileError.new(message, 'REMOVE_ERROR')
87
- end
88
-
89
- result = execute(['cp', '-r', source, dest])
90
- if result.exit_code != 0
91
- message = "Could not copy file to #{dest}: #{result.stderr.string}"
92
- raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
93
- end
94
- end
95
- end
96
-
97
- def with_tmpscript(script)
98
- with_tempdir do |dir|
99
- dest = File.join(dir.to_s, File.basename(script))
100
- copy_file(script, dest)
101
- yield dest, dir
102
- end
103
- end
104
-
105
- # See if there's a sudo prompt in the output
106
- # If not, return the output
107
- def check_sudo(out, inp, pid, stdin)
108
- buffer = out.readpartial(CHUNK_SIZE)
109
- # Split on newlines, including the newline
110
- lines = buffer.split(/(?<=[\n])/)
111
- # handle_sudo will return the line if it is not a sudo prompt or error
112
- lines.map! { |line| handle_sudo(inp, line, pid, stdin) }
113
- lines.join("")
114
- # If stream has reached EOF, no password prompt is expected
115
- # return an empty string
116
- rescue EOFError
117
- ''
118
- end
119
-
120
- def execute(command, sudoable: true, **options)
121
- run_as = options[:run_as] || self.run_as
122
- escalate = sudoable && run_as && @user != run_as
123
- use_sudo = escalate && @target.options['run-as-command'].nil?
124
-
125
- command_str = inject_interpreter(options[:interpreter], command)
126
-
127
- if escalate
128
- if use_sudo
129
- sudo_exec = target.options['sudo-executable'] || "sudo"
130
- sudo_flags = [sudo_exec, "-k", "-S", "-u", run_as, "-p", Sudoable.sudo_prompt]
131
- sudo_flags += ["-E"] if options[:environment]
132
- sudo_str = Shellwords.shelljoin(sudo_flags)
133
- else
134
- sudo_str = Shellwords.shelljoin(@target.options['run-as-command'] + [run_as])
135
- end
136
- command_str = build_sudoable_command_str(command_str, sudo_str, @sudo_id, options)
137
- end
138
-
139
- command_arr = options[:environment].nil? ? [command_str] : [options[:environment], command_str]
140
-
141
- # Prepare the variables!
142
- result_output = Bolt::Node::Output.new
143
- # Sudo handler will pass stdin if needed.
144
- in_buffer = !use_sudo && options[:stdin] ? options[:stdin] : ''
145
- # Chunks of this size will be read in one iteration
146
- index = 0
147
- timeout = 0.1
148
-
149
- inp, out, err, t = Open3.popen3(*command_arr)
150
- read_streams = { out => String.new,
151
- err => String.new }
152
- write_stream = in_buffer.empty? ? [] : [inp]
153
-
154
- # See if there's a sudo prompt
155
- if use_sudo
156
- ready_read = select([err], nil, nil, timeout * 5)
157
- read_streams[err] << check_sudo(err, inp, t.pid, options[:stdin]) if ready_read
158
- end
159
-
160
- # True while the process is running or waiting for IO input
161
- while t.alive?
162
- # See if we can read from out or err, or write to in
163
- ready_read, ready_write, = select(read_streams.keys, write_stream, nil, timeout)
164
-
165
- # Read from out and err
166
- ready_read&.each do |stream|
167
- # Check for sudo prompt
168
- read_streams[stream] << if use_sudo
169
- check_sudo(stream, inp, t.pid, options[:stdin])
170
- else
171
- stream.readpartial(CHUNK_SIZE)
172
- end
173
- rescue EOFError
174
- end
175
-
176
- # select will either return an empty array if there are no
177
- # writable streams or nil if no IO object is available before the
178
- # timeout is reached.
179
- writable = if ready_write.respond_to?(:empty?)
180
- !ready_write.empty?
181
- else
182
- !ready_write.nil?
183
- end
184
-
185
- begin
186
- if writable && index < in_buffer.length
187
- to_print = in_buffer[index..-1]
188
- written = inp.write_nonblock to_print
189
- index += written
190
-
191
- if index >= in_buffer.length && !write_stream.empty?
192
- inp.close
193
- write_stream = []
194
- end
195
- end
196
- # If a task has stdin as an input_method but doesn't actually
197
- # read from stdin, the task may return and close the input stream
198
- rescue Errno::EPIPE
199
- write_stream = []
200
- end
201
- end
202
- # Read any remaining data in the pipe. Do not wait for
203
- # EOF in case the pipe is inherited by a child process.
204
- read_streams.each do |stream, _|
205
- loop { read_streams[stream] << stream.read_nonblock(CHUNK_SIZE) }
206
- rescue Errno::EAGAIN, EOFError
207
- end
208
- result_output.stdout << read_streams[out]
209
- result_output.stderr << read_streams[err]
210
- result_output.exit_code = t.value.exitstatus
211
- result_output
212
- end
213
- end
214
- end
215
- end
216
- end
@@ -1,150 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'shellwords'
4
- require 'bolt/transport/base'
5
-
6
- module Bolt
7
- module Transport
8
- class Sudoable < Base
9
- def self.sudo_prompt
10
- '[sudo] Bolt needs to run as another user, password: '
11
- end
12
-
13
- def run_command(target, command, options = {})
14
- with_connection(target) do |conn|
15
- conn.running_as(options[:run_as]) do
16
- output = conn.execute(command, sudoable: true)
17
- Bolt::Result.for_command(target,
18
- output.stdout.string,
19
- output.stderr.string,
20
- output.exit_code,
21
- 'command', command)
22
- end
23
- end
24
- end
25
-
26
- def upload(target, source, destination, options = {})
27
- with_connection(target) do |conn|
28
- conn.running_as(options[:run_as]) do
29
- conn.with_tempdir do |dir|
30
- basename = File.basename(destination)
31
- tmpfile = File.join(dir.to_s, basename)
32
- conn.copy_file(source, tmpfile)
33
- # pass over file ownership if we're using run-as to be a different user
34
- dir.chown(conn.run_as)
35
- result = conn.execute(['mv', '-f', tmpfile, destination], sudoable: true)
36
- if result.exit_code != 0
37
- message = "Could not move temporary file '#{tmpfile}' to #{destination}: #{result.stderr.string}"
38
- raise Bolt::Node::FileError.new(message, 'MV_ERROR')
39
- end
40
- end
41
- Bolt::Result.for_upload(target, source, destination)
42
- end
43
- end
44
- end
45
-
46
- def run_script(target, script, arguments, options = {})
47
- # unpack any Sensitive data
48
- arguments = unwrap_sensitive_args(arguments)
49
-
50
- with_connection(target) do |conn|
51
- conn.running_as(options[:run_as]) do
52
- conn.with_tempdir do |dir|
53
- path = conn.write_executable(dir.to_s, script)
54
- dir.chown(conn.run_as)
55
- output = conn.execute([path, *arguments], sudoable: true)
56
- Bolt::Result.for_command(target,
57
- output.stdout.string,
58
- output.stderr.string,
59
- output.exit_code,
60
- 'script', script)
61
- end
62
- end
63
- end
64
- end
65
-
66
- def run_task(target, task, arguments, options = {})
67
- implementation = select_implementation(target, task)
68
- executable = implementation['path']
69
- input_method = implementation['input_method']
70
- extra_files = implementation['files']
71
-
72
- with_connection(target) do |conn|
73
- conn.running_as(options[:run_as]) do
74
- stdin, output = nil
75
- execute_options = {}
76
- execute_options[:interpreter] = select_interpreter(executable, target.options['interpreters'])
77
- interpreter_debug = if execute_options[:interpreter]
78
- " using '#{execute_options[:interpreter]}' interpreter"
79
- end
80
- # log the arguments with sensitive data redacted, do NOT log unwrapped_arguments
81
- logger.debug("Running '#{executable}' with #{arguments.to_json}#{interpreter_debug}")
82
- # unpack any Sensitive data
83
- arguments = unwrap_sensitive_args(arguments)
84
-
85
- conn.with_tempdir do |dir|
86
- if extra_files.empty?
87
- task_dir = dir
88
- else
89
- # TODO: optimize upload of directories
90
- arguments['_installdir'] = dir.to_s
91
- task_dir = File.join(dir.to_s, task.tasks_dir)
92
- dir.mkdirs([task.tasks_dir] + extra_files.map { |file| File.dirname(file['name']) })
93
- extra_files.each do |file|
94
- conn.copy_file(file['path'], File.join(dir.to_s, file['name']))
95
- end
96
- end
97
-
98
- if STDIN_METHODS.include?(input_method)
99
- stdin = JSON.dump(arguments)
100
- end
101
-
102
- if ENVIRONMENT_METHODS.include?(input_method)
103
- execute_options[:environment] = envify_params(arguments)
104
- end
105
-
106
- remote_task_path = conn.write_executable(task_dir, executable)
107
-
108
- # Avoid the horrors of passing data on stdin via a tty on multiple platforms
109
- # by writing a wrapper script that directs stdin to the task.
110
- if stdin && target.options['tty']
111
- wrapper = make_wrapper_stringio(remote_task_path, stdin, execute_options[:interpreter])
112
- execute_options.delete(:interpreter)
113
- execute_options[:wrapper] = true
114
- remote_task_path = conn.write_executable(dir, wrapper, 'wrapper.sh')
115
- end
116
-
117
- dir.chown(conn.run_as)
118
-
119
- execute_options[:stdin] = stdin
120
- execute_options[:sudoable] = true if conn.run_as
121
- output = conn.execute(remote_task_path, execute_options)
122
- end
123
- Bolt::Result.for_task(target, output.stdout.string,
124
- output.stderr.string,
125
- output.exit_code,
126
- task.name)
127
- end
128
- end
129
- end
130
-
131
- def make_wrapper_stringio(task_path, stdin, interpreter = nil)
132
- if interpreter
133
- StringIO.new(<<~SCRIPT)
134
- #!/bin/sh
135
- '#{interpreter}' '#{task_path}' <<'EOF'
136
- #{stdin}
137
- EOF
138
- SCRIPT
139
- else
140
- StringIO.new(<<~SCRIPT)
141
- #!/bin/sh
142
- '#{task_path}' <<'EOF'
143
- #{stdin}
144
- EOF
145
- SCRIPT
146
- end
147
- end
148
- end
149
- end
150
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'bolt/transport/sudoable/tmpdir'
4
-
5
- module Bolt
6
- module Transport
7
- class Sudoable < Base
8
- class Connection
9
- attr_accessor :target
10
-
11
- def initialize(target)
12
- @target = target
13
- @run_as = nil
14
- @logger = Logging.logger[@target.safe_name]
15
- end
16
-
17
- # This method allows the @run_as variable to be used as a per-operation
18
- # override for the user to run as. When @run_as is unset, the user
19
- # specified on the target will be used.
20
- def run_as
21
- @run_as || target.options['run-as']
22
- end
23
-
24
- # Run as the specified user for the duration of the block.
25
- def running_as(user)
26
- @run_as = user
27
- yield
28
- ensure
29
- @run_as = nil
30
- end
31
-
32
- def make_executable(path)
33
- result = execute(['chmod', 'u+x', path])
34
- if result.exit_code != 0
35
- message = "Could not make file '#{path}' executable: #{result.stderr.string}"
36
- raise Bolt::Node::FileError.new(message, 'CHMOD_ERROR')
37
- end
38
- end
39
-
40
- def make_tempdir
41
- tmpdir = @target.options.fetch('tmpdir', '/tmp')
42
- script_dir = @target.options.fetch('script-dir', SecureRandom.uuid)
43
- tmppath = File.join(tmpdir, script_dir)
44
- command = ['mkdir', '-m', 700, tmppath]
45
-
46
- result = execute(command)
47
- if result.exit_code != 0
48
- raise Bolt::Node::FileError.new("Could not make tempdir: #{result.stderr.string}", 'TEMPDIR_ERROR')
49
- end
50
- path = tmppath || result.stdout.string.chomp
51
- Sudoable::Tmpdir.new(self, path)
52
- end
53
-
54
- def write_executable(dir, file, filename = nil)
55
- filename ||= File.basename(file)
56
- remote_path = File.join(dir.to_s, filename)
57
- copy_file(file, remote_path)
58
- make_executable(remote_path)
59
- remote_path
60
- end
61
-
62
- # A helper to create and delete a tempdir on the remote system. Yields the
63
- # directory name.
64
- def with_tempdir
65
- dir = make_tempdir
66
- yield dir
67
- ensure
68
- dir&.delete
69
- end
70
-
71
- def execute(*_args)
72
- message = "#{self.class.name} must implement #{method} to execute commands"
73
- raise NotImplementedError, message
74
- end
75
-
76
- # In the case where a task is run with elevated privilege and needs stdin
77
- # a random string is echoed to stderr indicating that the stdin is available
78
- # for task input data because the sudo password has already either been
79
- # provided on stdin or was not needed.
80
- def prepend_sudo_success(sudo_id, command_str, reset_cwd)
81
- command_str = "cd && #{command_str}" if reset_cwd
82
- "sh -c 'echo #{sudo_id} 1>&2; #{command_str}'"
83
- end
84
-
85
- def prepend_chdir(command_str)
86
- "sh -c 'cd && #{command_str}'"
87
- end
88
-
89
- # A helper to build up a single string that contains all of the options for
90
- # privilege escalation. A wrapper script is used to direct task input to stdin
91
- # when a tty is allocated and thus we do not need to prepend_sudo_success when
92
- # using the wrapper or when the task does not require stdin data.
93
- def build_sudoable_command_str(command_str, sudo_str, sudo_id, options)
94
- if options[:stdin] && !options[:wrapper]
95
- "#{sudo_str} #{prepend_sudo_success(sudo_id, command_str, options[:reset_cwd])}"
96
- elsif options[:reset_cwd]
97
- "#{sudo_str} #{prepend_chdir(command_str)}"
98
- else
99
- "#{sudo_str} #{command_str}"
100
- end
101
- end
102
-
103
- # Returns string with the interpreter conditionally prepended
104
- def inject_interpreter(interpreter, command)
105
- if interpreter
106
- if command.is_a?(Array)
107
- command.unshift(interpreter)
108
- else
109
- command = [interpreter, command]
110
- end
111
- end
112
-
113
- command.is_a?(String) ? command : Shellwords.shelljoin(command)
114
- end
115
- end
116
- end
117
- end
118
- end