253 lines
8.5 KiB
Ruby

# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1
require File.expand_path("../../ssh/communicator", __FILE__)
require 'net/sftp'
module VagrantPlugins
module CommunicatorWinSSH
# This class provides communication with a Windows VM running
# the Windows native port of OpenSSH
class Communicator < VagrantPlugins::CommunicatorSSH::Communicator
# Command to run when checking if connection is ready and working
READY_COMMAND="dir"
def initialize(machine)
super
@logger = Log4r::Logger.new("vagrant::communication::winssh")
end
# Wrap the shell if required. By default we are using powershell
# which requires no modification. If cmd is defined as shell, add
# prefix to start within cmd.exe
def shell_cmd(opts)
case opts[:shell].to_s
when "cmd"
"cmd.exe /c '#{opts[:command]}'"
else
opts[:command]
end
end
# Executes the command on an SSH connection within a login shell.
def shell_execute(connection, command, **opts)
opts[:shell] ||= machine_config_ssh.shell
command = shell_cmd(opts.merge(command: command))
@logger.info("Execute: #{command} - opts: #{opts}")
exit_status = nil
# Open the channel so we can execute or command
channel = connection.open_channel do |ch|
marker_found = false
data_buffer = ''
stderr_marker_found = false
stderr_data_buffer = ''
@logger.debug("Base SSH exec command: #{command}")
command = "$ProgressPreference = 'SilentlyContinue';Write-Output #{CMD_GARBAGE_MARKER};[Console]::Error.WriteLine('#{CMD_GARBAGE_MARKER}');#{command}"
ch.exec(command) do |ch2, _|
# Setup the channel callbacks so we can get data and exit status
ch2.on_data do |ch3, data|
# Filter out the clear screen command
data = remove_ansi_escape_codes(data)
if !marker_found
data_buffer << data
marker_index = data_buffer.index(CMD_GARBAGE_MARKER)
if marker_index
marker_found = true
data_buffer.slice!(0, marker_index + CMD_GARBAGE_MARKER.size)
data.replace(data_buffer)
data_buffer = nil
end
end
if block_given? && marker_found
yield :stdout, data
end
end
ch2.on_extended_data do |ch3, type, data|
# Filter out the clear screen command
data = remove_ansi_escape_codes(data)
@logger.debug("stderr: #{data}")
if !stderr_marker_found
stderr_data_buffer << data
marker_index = stderr_data_buffer.index(CMD_GARBAGE_MARKER)
if marker_index
stderr_marker_found = true
stderr_data_buffer.slice!(0, marker_index + CMD_GARBAGE_MARKER.size)
data.replace(stderr_data_buffer.lstrip)
data_buffer = nil
end
end
if block_given? && stderr_marker_found && !data.empty?
yield :stderr, data
end
end
ch2.on_request("exit-status") do |ch3, data|
exit_status = data.read_long
@logger.debug("Exit status: #{exit_status}")
# Close the channel, since after the exit status we're
# probably done. This fixes up issues with hanging.
ch.close
end
end
end
begin
keep_alive = nil
if @machine.config.ssh.keep_alive
# Begin sending keep-alive packets while we wait for the script
# to complete. This avoids connections closing on long-running
# scripts.
keep_alive = Thread.new do
loop do
sleep 5
@logger.debug("Sending SSH keep-alive...")
connection.send_global_request("keep-alive@openssh.com")
end
end
end
# Wait for the channel to complete
begin
channel.wait
rescue Errno::ECONNRESET, IOError
@logger.info(
"SSH connection unexpected closed. Assuming reboot or something.")
exit_status = 0
pty = false
rescue Net::SSH::ChannelOpenFailed
raise Vagrant::Errors::SSHChannelOpenFail
rescue Net::SSH::Disconnect
raise Vagrant::Errors::SSHDisconnected
end
ensure
# Kill the keep-alive thread
keep_alive.kill if keep_alive
end
# Return the final exit status
return exit_status
end
def machine_config_ssh
@machine.config.winssh
end
def download(from, to=nil)
@logger.debug("Downloading: #{from} to #{to}")
sftp_connect do |sftp|
sftp.download!(from, to)
end
end
# Note: I could not get Net::SFTP to throw a permissions denied error,
# even when uploading to a directory where I did not have write
# privileges. I believe this is because Windows SSH sessions are started
# in an elevated process.
def upload(from, to)
to = Vagrant::Util::Platform.unix_windows_path(to)
@logger.debug("Uploading: #{from} to #{to}")
if File.directory?(from)
if from.end_with?(".")
@logger.debug("Uploading directory contents of: #{from}")
from = from.sub(/\.$/, "")
else
@logger.debug("Uploading full directory container of: #{from}")
to = File.join(to, File.basename(File.expand_path(from)))
end
end
sftp_connect do |sftp|
uploader = lambda do |path, remote_dest=nil|
if File.directory?(path)
Dir.new(path).each do |entry|
next if entry == "." || entry == ".."
full_path = File.join(path, entry)
dest = File.join(to, path.sub(/^#{Regexp.escape(from)}/, ""))
sftp.mkdir(dest)
uploader.call(full_path, dest)
end
else
if remote_dest
dest = File.join(remote_dest, File.basename(path))
else
dest = to
if to.end_with?(File::SEPARATOR)
dest = File.join(to, File.basename(path))
end
end
@logger.debug("Ensuring remote directory exists for destination upload")
sftp.mkdir(File.dirname(dest))
@logger.debug("Uploading file #{path} to remote #{dest}")
upload_file = File.open(path, "rb")
begin
sftp.upload!(upload_file, dest)
ensure
upload_file.close
end
end
end
uploader.call(from)
end
end
# Opens an SFTP connection and yields it so that you can download and
# upload files. SFTP works more reliably than SCP on Windows due to
# issues with shell quoting and escaping.
def sftp_connect
# Connect to SFTP and yield the SFTP object
connect do |connection|
return yield connection.sftp
end
end
protected
# The WinSSH communicator connection provides isolated modification
# to the generated connection instances. This modification forces
# all provided commands to run within powershell
def connect(**opts)
connection = nil
super { |c| connection = c }
if !connection.instance_variable_get(:@winssh_patched)
open_chan = connection.method(:open_channel)
connection.define_singleton_method(:open_channel) do |*args, &chan_block|
open_chan.call(*args) do |ch|
exec = ch.method(:exec)
ch.define_singleton_method(:exec) do |command, &block|
command = Base64.strict_encode64(command.encode("UTF-16LE", "UTF-8"))
command = "powershell -NoLogo -NonInteractive -ExecutionPolicy Bypass " \
"-NoProfile -EncodedCommand #{command}"
exec.call(command, &block)
end
chan_block.call(ch)
end
end
connection.instance_variable_set(:@winssh_patched, true)
end
if block_given?
yield connection
else
connection
end
end
end
end
end