1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
|
require_relative "../errors"
require_relative "../extras"
require_relative "../file"
require "fileutils" unless defined?(FileUtils)
require "logger"
require_relative "../audit_log"
class Train::Plugins::Transport
# A Connection instance can be generated and re-generated, given new
# connection details such as connection port, hostname, credentials, etc.
# This object is responsible for carrying out the actions on the remote
# host such as executing commands, transferring files, etc.
#
# @author Fletcher Nichol <fnichol@nichol.ca>
class BaseConnection
include Train::Extras
# Create a new Connection instance.
#
# @param options [Hash] connection options
# @yield [self] yields itself for block-style invocation
def initialize(options = nil)
@options = options || {}
@logger = @options.delete(:logger) || Logger.new($stdout, level: :fatal)
Train::Platforms::Detect::Specifications::OS.load
Train::Platforms::Detect::Specifications::Api.load
# In run_command all options are not accessible as some of them gets deleted in transit.
# To make the data like hostname, username available to aduit logs dup the options
@audit_log_data = options.dup || {}
# For transport other than local all audit log options accessible inside transport_options key
if !@options.empty? && @options[:transport_options] && @options[:transport_options][:enable_audit_log]
@audit_log = Train::AuditLog.create(options[:transport_options])
elsif !@options.empty? && @options[:enable_audit_log]
@audit_log = Train::AuditLog.create(@options)
end
# default caching options
@cache_enabled = {
file: true,
command: false,
api_call: false,
}
@cache = {}
@cache_enabled.each_key do |type|
clear_cache(type)
end
end
def with_sudo_pty
yield
end
# Returns cached client if caching enabled. Otherwise returns whatever is
# given in the block.
#
# @example
#
# def demo_client
# cached_client(:api_call, :demo_connection) do
# DemoClient.new(args)
# end
# end
#
# @param [symbol] type one of [:api_call, :file, :command]
# @param [symbol] key for your cached connection
def cached_client(type, key)
return yield unless cache_enabled?(type)
@cache[type][key] ||= yield
end
def cache_enabled?(type)
@cache_enabled[type.to_sym]
end
# Enable caching types for Train. Currently we support
# :api_call, :file and :command types
def enable_cache(type)
raise Train::UnknownCacheType, "#{type} is not a valid cache type" unless @cache_enabled.keys.include?(type.to_sym)
@cache_enabled[type.to_sym] = true
end
def disable_cache(type)
raise Train::UnknownCacheType, "#{type} is not a valid cache type" unless @cache_enabled.keys.include?(type.to_sym)
@cache_enabled[type.to_sym] = false
clear_cache(type.to_sym)
end
# Closes the session connection, if it is still active.
def close
# this method may be left unimplemented if that is applicable
end
def to_json
{
"files" => Hash[@cache[:file].map { |x, y| [x, y.to_json] }],
}
end
def load_json(j)
require_relative "../transports/mock"
j["files"].each do |path, jf|
@cache[:file][path] = Train::Transports::Mock::Connection::File.from_json(jf)
end
end
def force_platform!(name, platform_details = nil)
plat = Train::Platforms.name(name)
plat.backend = self
plat.platform = platform_details unless platform_details.nil?
plat.find_family_hierarchy
plat.add_platform_methods
plat
end
def backend_type
@options[:backend] || "unknown"
end
def inspect
"%s[%s]" % [self.class, backend_type]
end
alias direct_platform force_platform!
# Get information on the operating system which this transport connects to.
#
# @return [Platform] system information
def platform
@platform ||= Train::Platforms::Detect.scan(self)
end
# we need to keep os as a method for backwards compatibility with inspec
alias os platform
# This is the main command call for all connections. This will call the private
# run_command_via_connection on the connection with optional caching
#
# This command accepts an optional data handler block. When provided,
# inbound data will be published vi `data_handler.call(data)`. This can allow
# callers to receive and render updates from remote command execution.
#
# @param [String] the command to run
# @param [Hash] optional hash of options for this command. The derived connection
# class's implementation of run_command_via_connection should receive
# and apply these options.
def run_command(cmd, opts = {}, &data_handler)
# Some implementations do not accept an opts argument.
# We cannot update all implementations to accept opts due to them being separate plugins.
# Therefore here we check the implementation's arity to maintain compatibility.
@audit_log.info({ type: "cmd", command: "#{cmd}", user: @audit_log_data[:username], hostname: @audit_log_data[:hostname] }) if @audit_log
case method(:run_command_via_connection).arity.abs
when 1
return run_command_via_connection(cmd, &data_handler) unless cache_enabled?(:command)
@cache[:command][cmd] ||= run_command_via_connection(cmd, &data_handler)
when 2
return run_command_via_connection(cmd, opts, &data_handler) unless cache_enabled?(:command)
@cache[:command][cmd] ||= run_command_via_connection(cmd, opts, &data_handler)
else
raise NotImplementedError, "#{self.class} does not implement run_command_via_connection with arity #{method(:run_command_via_connection).arity}"
end
end
# This is the main file call for all connections. This will call the private
# file_via_connection on the connection with optional caching
def file(path, *args)
@audit_log.info({ type: "file", path: "#{path}", user: @audit_log_data[:username], hostname: @audit_log_data[:hostname] }) if @audit_log
return file_via_connection(path, *args) unless cache_enabled?(:file)
@cache[:file][path] ||= file_via_connection(path, *args)
end
# Uploads local files to remote host.
#
# @param locals [String, Array<String>] path to local files
# @param remote [String] path to remote destination
# @raise [TransportFailed] if the files could not all be uploaded
# successfully, which may vary by implementation
def upload(locals, remote)
remote_directory = file(remote).directory?
if locals.is_a?(Array) && !remote_directory
raise Train::TransportError, "#{self.class} expects remote directory as second upload parameter for multi-file uploads"
end
Array(locals).each do |local|
remote_file = remote_directory ? File.join(remote, File.basename(local)) : remote
@audit_log.info({ type: "file upload", source: local, destination: remote_file, user: @audit_log_data[:username], hostname: @audit_log_data[:hostname] }) if @audit_log
logger.debug("Attempting to upload '#{local}' as file #{remote_file}")
file(remote_file).content = File.read(local)
end
end
# Download remote files or directories to local host.
#
# @param remotes [Array<String>] paths to remote files or directories
# @param local [String] path to local destination. If `local` is an
# existing directory, `remote` will be downloaded into the directory
# using its original name
# @raise [TransportFailed] if the files could not all be downloaded
# successfully, which may vary by implementation
def download(remotes, local)
FileUtils.mkdir_p(File.dirname(local))
Array(remotes).each do |remote|
new_content = file(remote).content
local_file = File.join(local, File.basename(remote))
logger.debug("Attempting to download '#{remote}' as file #{local_file}")
File.open(local_file, "w") { |fp| fp.write(new_content) }
end
end
# Builds a LoginCommand which can be used to open an interactive
# session on the remote host.
#
# @return [LoginCommand] array of command line tokens
def login_command
raise NotImplementedError, "#{self.class} does not implement #login_command()"
end
# Block and return only when the remote host is prepared and ready to
# execute command and upload files. The semantics and details will
# vary by implementation, but a round trip through the hosted
# service is preferred to simply waiting on a socket to become
# available.
def wait_until_ready
# this method may be left unimplemented if that is applicable log
end
private
# Execute a command using this connection.
#
# @param command [String] command string to execute
# @param &data_handler(data) [Proc] proc that is called when data arrives from
# the connection. This block is optional. Individual transports will need
# to explicitly invoke the block in their implementation of run_command_via_connection;
# if they do not, the block is ignored and will not be used to report data back to the caller.
#
# @return [CommandResult] contains the result of running the command
def run_command_via_connection(_command, &_data_handler)
raise NotImplementedError, "#{self.class} does not implement #run_command_via_connection()"
end
# Interact with files on the target. Read, write, and get metadata
# from files via the transport.
#
# @param [String] path which is being inspected
# @return [FileCommon] file object that allows for interaction
def file_via_connection(_path, *_args)
raise NotImplementedError, "#{self.class} does not implement #file_via_connection(...)"
end
def clear_cache(type)
@cache[type.to_sym] = {}
end
# @return [Logger] logger for reporting information
# @api private
attr_reader :logger
# @return [Hash] connection options
# @api private
attr_reader :options
end
end
|