Skip to content

Commit

Permalink
Move Windows local command logic to separate class
Browse files Browse the repository at this point in the history
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
  • Loading branch information
jerryaldrichiii committed Nov 27, 2017
1 parent 3bd8ae5 commit b6a1d51
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 119 deletions.
41 changes: 0 additions & 41 deletions lib/train/extras/command_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,43 +121,6 @@ def safe_string(str)
end
end

# this is required if you run locally on windows,
# winrm connections provide a PowerShell shell automatically
# TODO: only activate in local mode
class PowerShellCommand < CommandWrapperBase
Train::Options.attach(self)

def initialize(backend, options)
@backend = backend
validate_options(options)
end

def run(script)
# wrap the script to ensure we always run it via powershell
# especially in local mode, we cannot be sure that we get a Powershell
# we may just get a `cmd`.
# TODO: we may want to opt for powershell.exe -command instead of `encodeCommand`
"powershell -NoProfile -encodedCommand #{encoded(safe_script(script))}"
end

# suppress the progress stream from leaking to stderr
def safe_script(script)
"$ProgressPreference='SilentlyContinue';" + script
end

# Encodes the script so that it can be passed to the PowerShell
# --EncodedCommand argument.
# @return [String] The UTF-16LE base64 encoded script
def encoded(script)
encoded_script = safe_script(script).encode('UTF-16LE', 'UTF-8')
Base64.strict_encode64(encoded_script)
end

def to_s
'PowerShell CommandWrapper'
end
end

class CommandWrapper
include_options LinuxCommand

Expand All @@ -168,10 +131,6 @@ def self.load(transport, options)
msg = res.verify
fail Train::UserError, "Sudo failed: #{msg}" unless msg.nil?
res
# only use powershell command for local transport. winrm transport
# uses powershell as default
elsif transport.os.windows? && transport.class == Train::Transports::Local::Connection
PowerShellCommand.new(transport, options)
end
end
end
Expand Down
195 changes: 117 additions & 78 deletions lib/train/transports/local.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,18 @@ def connection(_ = nil)
@connection ||= Connection.new(@options)
end

class Connection < BaseConnection # rubocop:disable Metrics/ClassLength
require 'json'
require 'base64'
require 'securerandom'

class Connection < BaseConnection
def initialize(options)
super(options)
@cmd_wrapper = nil
@cmd_wrapper = CommandWrapper.load(self, options)
if @platform.windows?
begin
@pipe = acquire_named_pipe
rescue
@pipe = false
end
end
@platform = platform
end

def run_command(cmd)
if defined?(@pipe) && @pipe
res = run_powershell_using_named_pipe(cmd)
CommandResult.new(res['stdout'], res['stderr'], res['exitstatus'])
if defined?(@platform) && @platform.windows?
@windows_runner ||= WindowsRunner.new
@windows_runner.run_command(cmd)
else
cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil?
res = Mixlib::ShellOut.new(cmd)
Expand All @@ -53,12 +43,11 @@ def local?
end

def file(path)
@files[path] ||= \
if os.windows?
Train::File::Local::Windows.new(self, path)
else
Train::File::Local::Unix.new(self, path)
end
@files[path] ||= if os.windows?
Train::File::Local::Windows.new(self, path)
else
Train::File::Local::Unix.new(self, path)
end
end

def login_command
Expand All @@ -69,81 +58,131 @@ def uri
'local://'
end

private
class WindowsRunner
require 'json'
require 'base64'
require 'securerandom'

def initialize
@pipe = acquire_pipe
end

def run_command(cmd)
res = @pipe ? run_via_pipe(cmd) : run_via_shellout(cmd)
Local::CommandResult.new(res.stdout, res.stderr, res.exitstatus)
rescue Errno::ENOENT => _
CommandResult.new('', '', 1)
end

private

def acquire_named_pipe
pipe_name = "inspec_#{SecureRandom.hex}"
pipe_location = "//localhost/pipe/#{pipe_name}"
start_named_pipe_server(pipe_name) unless File.exist?(pipe_location)
def acquire_pipe
pipe_name = Dir.entries('//./pipe/').find { |f| f =~ /inspec_/ }

return create_pipe("inspec_#{SecureRandom.hex}") if pipe_name.nil?

# Try to acquire pipe for 10 seconds with 0.1 second intervals.
# This allows time for PowerShell to start the pipe
pipe = nil
100.times do
begin
pipe = open(pipe_location, 'r+')
break
pipe = open("//./pipe/#{pipe_name}", 'r+')
rescue
sleep 0.1
# Pipes are closed when a Train connection ends. When running
# multiple independent scans (e.g. Unit tests) the pipe will be
# unavailable because the previous process is closing it.
# This creates a new pipe in that case
pipe = create_pipe("inspec_#{SecureRandom.hex}")
end

return false if pipe.nil?

pipe
end
fail "Could not open named pipe #{pipe_location}" if pipe.nil?

pipe
end
def create_pipe(pipe_name)
start_pipe_server(pipe_name)

def run_powershell_using_named_pipe(script)
script = "$ProgressPreference='SilentlyContinue';" + script
encoded_script = Base64.strict_encode64(script)
@pipe.puts(encoded_script)
@pipe.flush
JSON.parse(Base64.decode64(@pipe.readline))
end
pipe = nil

def start_named_pipe_server(pipe_name)
require 'win32/process'
# PowerShell needs time to create pipe.
100.times do
begin
pipe = open("//./pipe/#{pipe_name}", 'r+')
break
rescue
sleep 0.1
end
end

script = <<-EOF
$ErrorActionPreference = 'Stop'
return false if pipe.nil?

$pipeServer = New-Object System.IO.Pipes.NamedPipeServerStream('#{pipe_name}')
$pipeReader = New-Object System.IO.StreamReader($pipeServer)
$pipeWriter = New-Object System.IO.StreamWriter($pipeServer)
pipe
end

$pipeServer.WaitForConnection()
def run_via_shellout(script)
# Prevent progress stream from leaking into stderr
script = "$ProgressPreference='SilentlyContinue';" + script

# Create loop to receive and process user commands/scripts
$clientConnected = $true
while($clientConnected) {
$input = $pipeReader.ReadLine()
$command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($input))
# Encode script so PowerShell can use it
script = script.encode('UTF-16LE', 'UTF-8')
base64_script = Base64.strict_encode64(script)

# Execute user command/script and convert result to JSON
$scriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock($command)
try {
$stdout = & $scriptBlock | Out-String
$result = @{ 'stdout' = $stdout ; 'stderr' = ''; 'exitstatus' = 0 }
} catch {
$stderr = $_ | Out-String
$result = @{ 'stdout' = ''; 'stderr' = $_; 'exitstatus' = 1 }
}
$resultJSON = $result | ConvertTo-JSON
cmd = "powershell -NoProfile -EncodedCommand #{base64_script}"

# Encode JSON in Base64 and write to pipe
$encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($resultJSON))
$pipeWriter.WriteLine($encodedResult)
$pipeWriter.Flush()
}
EOF
res = Mixlib::ShellOut.new(cmd)
res.run_command
end

def run_via_pipe(script)
script = "$ProgressPreference='SilentlyContinue';" + script
encoded_script = Base64.strict_encode64(script)
@pipe.puts(encoded_script)
@pipe.flush
OpenStruct.new(JSON.parse(Base64.decode64(@pipe.readline)))
end

utf8_script = script.encode('UTF-16LE', 'UTF-8')
base64_script = Base64.strict_encode64(utf8_script)
cmd = "powershell -NoProfile -ExecutionPolicy bypass -NonInteractive -EncodedCommand #{base64_script}"
def start_pipe_server(pipe_name)
require 'win32/process'

script = <<-EOF
$ErrorActionPreference = 'Stop'
$pipeServer = New-Object System.IO.Pipes.NamedPipeServerStream('#{pipe_name}')
$pipeReader = New-Object System.IO.StreamReader($pipeServer)
$pipeWriter = New-Object System.IO.StreamWriter($pipeServer)
$pipeServer.WaitForConnection()
# Create loop to receive and process user commands/scripts
$clientConnected = $true
while($clientConnected) {
$input = $pipeReader.ReadLine()
$command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($input))
# Execute user command/script and convert result to JSON
$scriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock($command)
try {
$stdout = & $scriptBlock | Out-String
$result = @{ 'stdout' = $stdout ; 'stderr' = ''; 'exitstatus' = 0 }
} catch {
$stderr = $_ | Out-String
$result = @{ 'stdout' = ''; 'stderr' = $_; 'exitstatus' = 1 }
}
$resultJSON = $result | ConvertTo-JSON
# Encode JSON in Base64 and write to pipe
$encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($resultJSON))
$pipeWriter.WriteLine($encodedResult)
$pipeWriter.Flush()
}
EOF

utf8_script = script.encode('UTF-16LE', 'UTF-8')
base64_script = Base64.strict_encode64(utf8_script)
cmd = "powershell -NoProfile -ExecutionPolicy bypass -NonInteractive -EncodedCommand #{base64_script}"

server_pid = Process.create(command_line: cmd).process_id
server_pid = Process.create(command_line: cmd).process_id

# Ensure process is killed when the Train process exits
at_exit { Process.kill('KILL', server_pid) }
# Ensure process is killed when the Train process exits
at_exit { Process.kill('KILL', server_pid) }
end
end
end
end
Expand Down
17 changes: 17 additions & 0 deletions test/windows/local_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@
cmd.stderr.must_equal ''
end

it 'when named pipe is not available it runs `Mixlib::Shellout`' do
# Must call `:conn` early so we can stub `:acquire_pipe`
connection = conn

# Prevent named pipe from being created
Train::Transports::Local::Connection::WindowsRunner
.any_instance
.stubs(:acquire_pipe)
.returns(false)

# Verify pipe was not created
SecureRandom.stubs(:hex).returns('minitest')
cmd = connection.run_command('Get-ChildItem //./pipe/ | Where-Object { $_.Name -Match "inspec_minitest" }')
cmd.stdout.must_equal ''
cmd.stderr.must_equal ''
end

describe 'file' do
before do
@temp = Tempfile.new('foo')
Expand Down

0 comments on commit b6a1d51

Please sign in to comment.