# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require "log4r"

require 'childprocess'

require "vagrant/util/file_mode"
require "vagrant/util/platform"
require "vagrant/util/safe_exec"
require "vagrant/util/safe_puts"
require "vagrant/util/subprocess"
require "vagrant/util/which"

module Vagrant
  module Util
    # This is a class that has helpers on it for dealing with SSH. These
    # helpers don't depend on any part of Vagrant except what is given
    # via the parameters.
    class SSH
      extend SafePuts

      LOGGER = Log4r::Logger.new("vagrant::util::ssh")

      # Checks that the permissions for a private key are valid, and fixes
      # them if possible. SSH requires that permissions on the private key
      # are 0600 on POSIX based systems. This will make a best effort to
      # fix these permissions if they are not properly set.
      #
      # @param [Pathname] key_path The path to the private key.
      def self.check_key_permissions(key_path)
        # Don't do anything if we're on Windows, since Windows doesn't worry
        # about key permissions.
        return if Platform.windows? || Platform.wsl_windows_access_bypass?(key_path)

        LOGGER.debug("Checking key permissions: #{key_path}")
        stat = key_path.stat

        if !stat.owned? && Process.uid != 0
          # The SSH key must be owned by ourselves, unless we're root
          raise Errors::SSHKeyBadOwner, key_path: key_path
        end

        if FileMode.from_octal(stat.mode) != "600"
          LOGGER.info("Attempting to correct key permissions to 0600")
          key_path.chmod(0600)

          # Re-stat the file to get the new mode, and verify it worked
          stat = key_path.stat
          if FileMode.from_octal(stat.mode) != "600"
            raise Errors::SSHKeyBadPermissions, key_path: key_path
          end
        end
      rescue Errno::EPERM
        # This shouldn't happen since we verify we own the file, but
        # it is possible in theory, so we raise an error.
        raise Errors::SSHKeyBadPermissions, key_path: key_path
      end

      # Halts the running of this process and replaces it with a full-fledged
      # SSH shell into a remote machine.
      #
      # Note: This method NEVER returns. The process ends after this.
      #
      # @param [Hash] ssh_info This is the SSH information. For the keys
      #   required please see the documentation of {Machine#ssh_info}.
      # @param [Hash] opts These are additional options that are supported
      #   by exec.
      def self.exec(ssh_info, opts={})
        # Ensure the platform supports ssh. On Windows there are several programs which
        # include ssh, notably git, mingw and cygwin, but make sure ssh is in the path!

        # First try using the original path provided
        if ENV["VAGRANT_PREFER_SYSTEM_BIN"] != "0"
          ssh_path = Which.which("ssh", original_path: true)
        end

        # If we didn't find an ssh executable, see if we shipped one
        if !ssh_path
          ssh_path = Which.which("ssh")
          if ssh_path && Platform.windows? && (Platform.cygwin? || Platform.msys?)
            LOGGER.warn("Failed to locate native SSH executable. Using vendored version.")
            LOGGER.warn("If display issues are encountered, install the ssh package for your environment.")
          end
        end

        if !ssh_path
          if Platform.windows?
            raise Errors::SSHUnavailableWindows,
              host: ssh_info[:host],
              port: ssh_info[:port],
              username: ssh_info[:username],
              key_path: ssh_info[:private_key_path].join(", ")
          end

          raise Errors::SSHUnavailable
        end

        if Platform.windows?
          # On Windows, we need to detect whether SSH is actually "plink"
          # underneath the covers. In this case, we tell the user.
          r = Subprocess.execute(ssh_path)
          if r.stdout.include?("PuTTY Link") || r.stdout.include?("Plink: command-line connection utility")
            raise Errors::SSHIsPuttyLink,
              host: ssh_info[:host],
              port: ssh_info[:port],
              username: ssh_info[:username],
              key_path: ssh_info[:private_key_path].join(", ")
          end
        end

        # If plain mode is enabled then we don't do any authentication (we don't
        # set a user or an identity file)
        plain_mode = opts[:plain_mode]

        options = {}
        options[:host] = ssh_info[:host]
        options[:port] = ssh_info[:port]
        options[:username] = ssh_info[:username]
        options[:private_key_path] = ssh_info[:private_key_path]

        log_level = ssh_info[:log_level] || "FATAL"

        # Command line options
        command_options = [
          "-p", options[:port].to_s,
          "-o", "LogLevel=#{log_level}"]

        if ssh_info[:compression]
          command_options += ["-o", "Compression=yes"]
        end

        if ssh_info[:dsa_authentication]
          command_options += ["-o", "DSAAuthentication=yes"]
        end

        # Solaris/OpenSolaris/Illumos uses SunSSH which doesn't support the
        # IdentitiesOnly option. Also, we don't enable it in plain mode or if
        # if keys_only is false so that SSH and Net::SSH properly search our identities
        # and tries to do it itself.
        if !Platform.solaris? && !plain_mode && ssh_info[:keys_only]
          command_options += ["-o", "IdentitiesOnly=yes"]
        end

        # no strict hostkey checking unless paranoid
        if ssh_info[:verify_host_key] == :never || !ssh_info[:verify_host_key]
          command_options += [
            "-o", "StrictHostKeyChecking=no",
            "-o", "UserKnownHostsFile=/dev/null"]
        end

        if !ssh_info[:disable_deprecated_algorithms]
          command_options += [
            "-o", "PubkeyAcceptedKeyTypes=+ssh-rsa",
            "-o", "HostKeyAlgorithms=+ssh-rsa",
          ]
        end

        # If we're not in plain mode and :private_key_path is set attach the private key path(s).
        if !plain_mode && options[:private_key_path]
          options[:private_key_path].each do |path|

            private_key_arr = []

            if path.include?('%')
              if path.include?(' ') && Platform.windows?
                LOGGER.warn("Paths with spaces and % on windows is not supported and will fail to read the file")
              end
              # Use '-o' instead of '-i' because '-i' does not call
              # percent_expand in misc.c, but '-o' does. when passing the path,
              # replace '%' in the path with '%%' to escape the '%'
              path = path.to_s.gsub('%', '%%')
              private_key_arr = ["-o", "IdentityFile=\"#{path}\""]
            else
              # Pass private key file directly with '-i', which properly supports
              # paths with spaces on Windows guests
              private_key_arr = ["-i", path]
            end

            command_options += private_key_arr
          end
        end

        if ssh_info[:forward_x11]
          # Both are required so that no warnings are shown regarding X11
          command_options += [
            "-o", "ForwardX11=yes",
            "-o", "ForwardX11Trusted=yes"]
        end

        if ssh_info[:config]
          command_options += ["-F", ssh_info[:config]]
        end

        if ssh_info[:proxy_command]
          command_options += ["-o", "ProxyCommand=#{ssh_info[:proxy_command]}"]
        end

        if ssh_info[:forward_env]
          command_options += ["-o", "SendEnv=#{ssh_info[:forward_env].join(" ")}"]
        end

        # Configurables -- extra_args should always be last due to the way the
        # ssh args parser works. e.g. if the user wants to use the -t option,
        # any shell command(s) she'd like to run on the remote server would
        # have to be the last part of the 'ssh' command:
        #
        #   $ ssh localhost -t -p 2222 "cd mydirectory; bash"
        #
        # Without having extra_args be last, the user loses this ability
        command_options += ["-o", "ForwardAgent=yes"] if ssh_info[:forward_agent]

        # Note about :extra_args
        #   ssh_info[:extra_args] comes from a machines ssh config in a Vagrantfile,
        #   where as opts[:extra_args] comes from running the ssh command
        command_options += Array(ssh_info[:extra_args]) if ssh_info[:extra_args]

        command_options.concat(opts[:extra_args]) if opts[:extra_args]

        # Build up the host string for connecting
        host_string = options[:host]
        host_string = "#{options[:username]}@#{host_string}" if !plain_mode
        command_options.unshift(host_string)

        # On Cygwin we want to get rid of any DOS file warnings because
        # we really don't care since both work.
        ENV["nodosfilewarning"] = "1" if Platform.cygwin?

        # If an ssh command is defined, use that. If an ssh binary was
        # discovered on the path, use that. Otherwise fail to just trying `ssh`
        ssh = ssh_info[:ssh_command] || ssh_path || 'ssh'

        # Invoke SSH with all our options
        if !opts[:subprocess]
          LOGGER.info("Invoking SSH: #{ssh} #{command_options.inspect}")
          _raw_exec(ssh, command_options, ssh_info, opts)
          return
        else
          LOGGER.info("Executing SSH in subprocess: #{ssh} #{command_options.inspect}")
          return _raw_subprocess(ssh, command_options, ssh_info, opts)
        end
      end

      def self._raw_exec(ssh, command_options, ssh_info, opts)
        SafeExec.exec(ssh, *command_options)
      end

      def self._raw_subprocess(ssh, command_options, ssh_info, opts)
        # If we're still here, it means we're supposed to subprocess
        # out to ssh rather than exec it.
        process = ChildProcess.build(ssh, *command_options)
        process.io.inherit!

        # Forward configured environment variables.
        if ssh_info[:forward_env]
          ssh_info[:forward_env].each do |key|
            process.environment[key] = ENV[key]
          end
        end

        process.start
        process.wait
        return process.exit_code
      end
    end
  end
end
