remotus 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +35 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +41 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +103 -0
- data/LICENSE.txt +21 -0
- data/README.md +112 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/remotus.rb +142 -0
- data/lib/remotus/auth.rb +66 -0
- data/lib/remotus/auth/credential.rb +145 -0
- data/lib/remotus/auth/hash_store.rb +56 -0
- data/lib/remotus/auth/store.rb +43 -0
- data/lib/remotus/host_pool.rb +181 -0
- data/lib/remotus/logger.rb +9 -0
- data/lib/remotus/pool.rb +189 -0
- data/lib/remotus/result.rb +83 -0
- data/lib/remotus/ssh_connection.rb +447 -0
- data/lib/remotus/version.rb +6 -0
- data/lib/remotus/winrm_connection.rb +186 -0
- data/remotus.gemspec +45 -0
- metadata +226 -0
data/lib/remotus/auth.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "remotus/auth/credential"
|
4
|
+
require "remotus/auth/store"
|
5
|
+
require "remotus/auth/hash_store"
|
6
|
+
|
7
|
+
module Remotus
|
8
|
+
# Module containing remote authentication classes and modules
|
9
|
+
module Auth
|
10
|
+
#
|
11
|
+
# Gets authentication credentials for the given connection and options
|
12
|
+
#
|
13
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection remote connection
|
14
|
+
# @param [Hash] options options hash
|
15
|
+
# options may be used by different credential stores.
|
16
|
+
#
|
17
|
+
# @return [Remotus::Auth::Credential] found credential
|
18
|
+
#
|
19
|
+
def self.credential(connection, **options)
|
20
|
+
return cache[connection.host] if cache.key?(connection.host)
|
21
|
+
|
22
|
+
stores.each do |store|
|
23
|
+
host_cred = store.credential(connection, **options)
|
24
|
+
if host_cred
|
25
|
+
cache[connection.host] = host_cred
|
26
|
+
return host_cred
|
27
|
+
end
|
28
|
+
end
|
29
|
+
raise Remotus::MissingCredential, "Could not find credential for #{connection.host} in any credential store (#{stores.join(", ")})."
|
30
|
+
end
|
31
|
+
|
32
|
+
#
|
33
|
+
# Gets the credential cache
|
34
|
+
#
|
35
|
+
# @return [Hash{String => Remotus::Auth::Credential}] credential cache with hostname keys
|
36
|
+
#
|
37
|
+
def self.cache
|
38
|
+
@cache ||= {}
|
39
|
+
end
|
40
|
+
|
41
|
+
#
|
42
|
+
# Clears all entries in the credential cache
|
43
|
+
#
|
44
|
+
def self.clear_cache
|
45
|
+
@cache = {}
|
46
|
+
end
|
47
|
+
|
48
|
+
#
|
49
|
+
# Gets the list of associated credential stores
|
50
|
+
#
|
51
|
+
# @return [Array<Remotus::Auth::Store>] credential stores
|
52
|
+
#
|
53
|
+
def self.stores
|
54
|
+
@stores ||= []
|
55
|
+
end
|
56
|
+
|
57
|
+
#
|
58
|
+
# Sets the list of associated credential stores
|
59
|
+
#
|
60
|
+
# @param [Array<Remotus::Auth::Store>] stores credential stores
|
61
|
+
#
|
62
|
+
def self.stores=(stores)
|
63
|
+
@stores = stores
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
|
5
|
+
module Remotus
|
6
|
+
module Auth
|
7
|
+
# Authentication credential
|
8
|
+
class Credential
|
9
|
+
# @return [String] gets or sets user
|
10
|
+
attr_accessor :user
|
11
|
+
|
12
|
+
# @return [String] gets or sets private key path
|
13
|
+
attr_accessor :private_key
|
14
|
+
|
15
|
+
#
|
16
|
+
# Generates a new credential from a hash
|
17
|
+
#
|
18
|
+
# @param [Hash] hash hash with :user, :password, :private_key, and :private_key_data keys
|
19
|
+
# @option hash [String] :user user name
|
20
|
+
# @option hash [String] :password user password
|
21
|
+
# @option hash [String] :private_key private key path
|
22
|
+
# @option hash [String] :private_key_data private key data as a string
|
23
|
+
#
|
24
|
+
# @return [Remotus::Auth::Credential] newly initialized credential
|
25
|
+
#
|
26
|
+
def self.from_hash(hash)
|
27
|
+
Credential.new(
|
28
|
+
hash[:user],
|
29
|
+
hash[:password],
|
30
|
+
private_key: hash[:private_key],
|
31
|
+
private_key_data: hash[:private_key_data]
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# Creates a new instance of a Remotus::Auth::Credential
|
37
|
+
#
|
38
|
+
# @param [String] user user name
|
39
|
+
# @param [String] password user password
|
40
|
+
# @param [Hash] options options hash
|
41
|
+
# @option options [String] :private_key private key path
|
42
|
+
# @option options [String] :private_key_data private key data as a string
|
43
|
+
#
|
44
|
+
def initialize(user, password = nil, **options)
|
45
|
+
@user = user
|
46
|
+
@crypt_info = { password: {}, private_key_data: {} }
|
47
|
+
@private_key = options[:private_key]
|
48
|
+
self.password = password
|
49
|
+
self.private_key_data = options[:private_key_data]
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Retrieved decrypted password
|
54
|
+
#
|
55
|
+
# @return [String, nil] decrypted password or nil if unset
|
56
|
+
#
|
57
|
+
def password
|
58
|
+
return unless @password
|
59
|
+
|
60
|
+
decrypt(@password, :password)
|
61
|
+
end
|
62
|
+
|
63
|
+
#
|
64
|
+
# Sets password
|
65
|
+
#
|
66
|
+
# @param [String] password new password
|
67
|
+
#
|
68
|
+
def password=(password)
|
69
|
+
@password = password ? encrypt(password.to_s, :password) : nil
|
70
|
+
end
|
71
|
+
|
72
|
+
#
|
73
|
+
# Retrieves decrypted private key data
|
74
|
+
#
|
75
|
+
# @return [String, nil] decrypted private key data or nil if unset
|
76
|
+
#
|
77
|
+
def private_key_data
|
78
|
+
return unless @private_key_data
|
79
|
+
|
80
|
+
decrypt(@private_key_data, :private_key_data)
|
81
|
+
end
|
82
|
+
|
83
|
+
#
|
84
|
+
# Sets private key data
|
85
|
+
#
|
86
|
+
# @param [String] private_key_data private key data
|
87
|
+
#
|
88
|
+
def private_key_data=(private_key_data)
|
89
|
+
@private_key_data = private_key_data ? encrypt(private_key_data.to_s, :private_key_data) : nil
|
90
|
+
end
|
91
|
+
|
92
|
+
#
|
93
|
+
# Converts credential to a string
|
94
|
+
#
|
95
|
+
# @return [String] Credential represented as a string
|
96
|
+
#
|
97
|
+
def to_s
|
98
|
+
"user: #{@user}"
|
99
|
+
end
|
100
|
+
|
101
|
+
#
|
102
|
+
# Inspects credential
|
103
|
+
#
|
104
|
+
# @return [String] Credential represented as an inspection string
|
105
|
+
#
|
106
|
+
def inspect
|
107
|
+
"#{self.class.name}: (#{self})"
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
#
|
113
|
+
# Encrypts string data
|
114
|
+
#
|
115
|
+
# @param [String] data data to encrypt
|
116
|
+
# @param [Symbol] crypt_key key in @crypt_info to store the key and iv for decryption
|
117
|
+
#
|
118
|
+
# @return [Object] encrypted data
|
119
|
+
#
|
120
|
+
def encrypt(data, crypt_key)
|
121
|
+
cipher = OpenSSL::Cipher.new("aes-256-cbc")
|
122
|
+
cipher.encrypt
|
123
|
+
@crypt_info[crypt_key][:key] = cipher.random_key
|
124
|
+
@crypt_info[crypt_key][:iv] = cipher.random_iv
|
125
|
+
cipher.update(data) + cipher.final
|
126
|
+
end
|
127
|
+
|
128
|
+
#
|
129
|
+
# Decrypts data to a string
|
130
|
+
#
|
131
|
+
# @param [Object] data encrypted data
|
132
|
+
# @param [Symbol] crypt_key key in @crypt_info containing the key and iv for decryption
|
133
|
+
#
|
134
|
+
# @return [String] decrypted string
|
135
|
+
#
|
136
|
+
def decrypt(data, crypt_key)
|
137
|
+
decipher = OpenSSL::Cipher.new("aes-256-cbc")
|
138
|
+
decipher.decrypt
|
139
|
+
decipher.key = @crypt_info[crypt_key][:key]
|
140
|
+
decipher.iv = @crypt_info[crypt_key][:iv]
|
141
|
+
decipher.update(data) + decipher.final
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Remotus
|
4
|
+
module Auth
|
5
|
+
# Hash-based authentication store that requires credentials to be added manually
|
6
|
+
class HashStore < Store
|
7
|
+
#
|
8
|
+
# Creates the HashStore
|
9
|
+
#
|
10
|
+
def initialize
|
11
|
+
super
|
12
|
+
@store = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
#
|
16
|
+
# Retrieves a credential from the hash store
|
17
|
+
#
|
18
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection <description>
|
19
|
+
# @param [Hash] _options unused options hash
|
20
|
+
#
|
21
|
+
# @return [Remotus::Auth::Credential, nil] found credential or nil
|
22
|
+
#
|
23
|
+
def credential(connection, **_options)
|
24
|
+
@store[connection.host.downcase]
|
25
|
+
end
|
26
|
+
|
27
|
+
#
|
28
|
+
# Adds a credential to the store for a given connection
|
29
|
+
#
|
30
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection associated connection
|
31
|
+
# @param [Remotus::Auth::Credential] credential new credential
|
32
|
+
#
|
33
|
+
def add(connection, credential)
|
34
|
+
@store[connection.host.downcase] = credential
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# Removes a credential from the store for a given connection
|
39
|
+
#
|
40
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection associated connection
|
41
|
+
#
|
42
|
+
def remove(connection)
|
43
|
+
@store.delete(connection.host.downcase)
|
44
|
+
end
|
45
|
+
|
46
|
+
#
|
47
|
+
# String representation of the hash store
|
48
|
+
#
|
49
|
+
# @return [String] string representation of the hash store
|
50
|
+
#
|
51
|
+
def to_s
|
52
|
+
"HashStore"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Remotus
|
4
|
+
module Auth
|
5
|
+
# Authentication store base class
|
6
|
+
class Store
|
7
|
+
#
|
8
|
+
# Base method fo retrieving a credential from the hash store.
|
9
|
+
# This must be overridden in derived classes or it will raise an exception.
|
10
|
+
#
|
11
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] _connection unused associated connection
|
12
|
+
# @param [Hash] _options unused options hash
|
13
|
+
#
|
14
|
+
def credential(_connection, **_options)
|
15
|
+
raise Remotus::MissingOverride, "credential method not implemented in credential store #{self.class}"
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# Gets the user for a given connection and options
|
20
|
+
#
|
21
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection associated connection
|
22
|
+
# @param [Hash] options options hash
|
23
|
+
#
|
24
|
+
# @return [String] user
|
25
|
+
#
|
26
|
+
def user(connection, **options)
|
27
|
+
credential(connection, **options)&.user
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Gets the password for a given connection and options
|
32
|
+
#
|
33
|
+
# @param [Remotus::SshConnection, Remotus::WinrmConnection, #host] connection associated connection
|
34
|
+
# @param [Hash] options options hash
|
35
|
+
#
|
36
|
+
# @return [String] password
|
37
|
+
#
|
38
|
+
def password(connection, **options)
|
39
|
+
credential(connection, **options)&.password
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "remotus"
|
4
|
+
require "remotus/auth"
|
5
|
+
require "remotus/ssh_connection"
|
6
|
+
require "remotus/winrm_connection"
|
7
|
+
require "connection_pool"
|
8
|
+
|
9
|
+
module Remotus
|
10
|
+
# Class representing a remote connection pool (SSH or WinRM) for a single host
|
11
|
+
class HostPool
|
12
|
+
# @return [Time] when the host pool will expire
|
13
|
+
attr_reader :expiration_time
|
14
|
+
|
15
|
+
# @return [Integer] size of the host connection pool
|
16
|
+
attr_reader :size
|
17
|
+
|
18
|
+
# @return [Integer] Number of seconds to wait for a connection from the pool
|
19
|
+
attr_reader :timeout
|
20
|
+
|
21
|
+
# @return [Symbol] host pool protocol (:ssh or :winrm)
|
22
|
+
attr_reader :proto
|
23
|
+
|
24
|
+
# @return [String] host pool remote host
|
25
|
+
attr_reader :host
|
26
|
+
|
27
|
+
# Number of seconds to wait for a connection from the pool by default
|
28
|
+
DEFAULT_EXPIRATION_SECONDS = 600
|
29
|
+
|
30
|
+
# Default size of the connection pool
|
31
|
+
DEFAULT_POOL_SIZE = 2
|
32
|
+
|
33
|
+
#
|
34
|
+
# Creates a host pool for a specific host
|
35
|
+
#
|
36
|
+
# @param [String] host hostname
|
37
|
+
# @param [Integer] size number of connections in the pool (optional)
|
38
|
+
# @param [Integer] timeout amount of time to wait for a connection from the pool (optional)
|
39
|
+
# @param [Integer] port port to use for the connection
|
40
|
+
# @param [Symbol] proto protocol to use for the connection (:winrm, :ssh), must be specified if port is specified
|
41
|
+
#
|
42
|
+
def initialize(host, size: DEFAULT_POOL_SIZE, timeout: DEFAULT_EXPIRATION_SECONDS, port: nil, proto: nil)
|
43
|
+
Remotus.logger.debug { "Creating host pool for #{host}" }
|
44
|
+
|
45
|
+
@host = host
|
46
|
+
@proto = proto || Remotus.host_type(host)
|
47
|
+
|
48
|
+
raise Remotus::HostTypeDeterminationError, "Could not determine whether to use SSH or WinRM for #{host}" unless @proto
|
49
|
+
|
50
|
+
connection_class = Object.const_get("Remotus::#{@proto.capitalize}Connection")
|
51
|
+
port ||= connection_class::REMOTE_PORT
|
52
|
+
|
53
|
+
@pool = ConnectionPool.new(size: size, timeout: timeout) { connection_class.new(host, port) }
|
54
|
+
@size = size.to_i
|
55
|
+
@timeout = timeout.to_i
|
56
|
+
@expiration_time = Time.now + timeout
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Whether the pool is currently expired
|
61
|
+
#
|
62
|
+
# @return [Boolean] whether pool is expired
|
63
|
+
#
|
64
|
+
def expired?
|
65
|
+
Time.now > @expiration_time
|
66
|
+
end
|
67
|
+
|
68
|
+
#
|
69
|
+
# Force immediate expiration of the pool
|
70
|
+
#
|
71
|
+
def expire
|
72
|
+
Remotus.logger.debug { "Expiring #{@proto} host pool #{object_id} (#{@host})" }
|
73
|
+
@expiration_time = Time.now
|
74
|
+
end
|
75
|
+
|
76
|
+
#
|
77
|
+
# Provides an SSH or WinRM connection to a given block of code
|
78
|
+
#
|
79
|
+
# @example Run a command over SSH or WinRM using a pooled connection
|
80
|
+
# pool.with { |c| c.run("ls") }
|
81
|
+
#
|
82
|
+
# @param [Hash] options options hash
|
83
|
+
# @option options [Integer] :timeout amount of time to wait for a connection if none is available
|
84
|
+
#
|
85
|
+
# @return [Object] return value of the provided block
|
86
|
+
#
|
87
|
+
def with(**options, &block)
|
88
|
+
# Update expiration time since the pool has been used
|
89
|
+
@expiration_time = Time.now + (@timeout + options[:timeout].to_i)
|
90
|
+
Remotus.logger.debug { "Updating #{@proto} host pool #{object_id} (#{@host}) expiration time to #{@expiration_time}" }
|
91
|
+
|
92
|
+
# Run the provided block against the connection
|
93
|
+
Remotus.logger.debug { "Running block in #{@proto} host pool #{object_id} (#{@host})" }
|
94
|
+
@pool.with(options, &block)
|
95
|
+
end
|
96
|
+
|
97
|
+
#
|
98
|
+
# Gets remote host connection port
|
99
|
+
# @see Remotus::SshConnection#port
|
100
|
+
# @see Remotus::WinrmConnection#port
|
101
|
+
#
|
102
|
+
def port
|
103
|
+
Remotus.logger.debug { "Getting port from #{@proto} host pool #{object_id} (#{@host})" }
|
104
|
+
with(&:port)
|
105
|
+
end
|
106
|
+
|
107
|
+
#
|
108
|
+
# Checks if connection port is open on the remote host
|
109
|
+
# @see Remotus::SshConnection#port_open?
|
110
|
+
# @see Remotus::WinrmConnection#port_open?
|
111
|
+
#
|
112
|
+
def port_open?
|
113
|
+
Remotus.logger.debug { "Checking if port is open from #{@proto} host pool #{object_id} (#{@host})" }
|
114
|
+
with(&:port_open?)
|
115
|
+
end
|
116
|
+
|
117
|
+
#
|
118
|
+
# Runs command on the remote host
|
119
|
+
# @see Remotus::SshConnection#run
|
120
|
+
# @see Remotus::WinrmConnection#run
|
121
|
+
#
|
122
|
+
def run(command, *args, **options)
|
123
|
+
with { |c| c.run(command, *args, **options) }
|
124
|
+
end
|
125
|
+
|
126
|
+
#
|
127
|
+
# Runs script on the remote host
|
128
|
+
# @see Remotus::SshConnection#run_script
|
129
|
+
# @see Remotus::WinrmConnection#run_script
|
130
|
+
#
|
131
|
+
def run_script(local_path, remote_path, *args, **options)
|
132
|
+
with { |c| c.run_script(local_path, remote_path, *args, **options) }
|
133
|
+
end
|
134
|
+
|
135
|
+
#
|
136
|
+
# Uploads file to the remote host
|
137
|
+
# @see Remotus::SshConnection#upload
|
138
|
+
# @see Remotus::WinrmConnection#upload
|
139
|
+
#
|
140
|
+
def upload(local_path, remote_path, **options)
|
141
|
+
with { |c| c.upload(local_path, remote_path, **options) }
|
142
|
+
end
|
143
|
+
|
144
|
+
#
|
145
|
+
# Downloads file from the remote host
|
146
|
+
# @see Remotus::SshConnection#download
|
147
|
+
# @see Remotus::WinrmConnection#download
|
148
|
+
#
|
149
|
+
def download(remote_path, local_path = nil, **options)
|
150
|
+
with { |c| c.download(remote_path, local_path, **options) }
|
151
|
+
end
|
152
|
+
|
153
|
+
#
|
154
|
+
# Checks if file exists on the remote host
|
155
|
+
# @see Remotus::SshConnection#file_exist?
|
156
|
+
# @see Remotus::WinrmConnection#file_exist?
|
157
|
+
#
|
158
|
+
def file_exist?(remote_path, **options)
|
159
|
+
with { |c| c.file_exist?(remote_path, **options) }
|
160
|
+
end
|
161
|
+
|
162
|
+
#
|
163
|
+
# Gets the current host credential (if any)
|
164
|
+
# @see Remotus::Auth#credential
|
165
|
+
#
|
166
|
+
def credential(**options)
|
167
|
+
with { |c| Remotus::Auth.credential(c, **options) }
|
168
|
+
end
|
169
|
+
|
170
|
+
#
|
171
|
+
# Sets the current host credential
|
172
|
+
#
|
173
|
+
# @param [Remotus::Auth::Credential, Hash] credential new credential
|
174
|
+
#
|
175
|
+
def credential=(credential)
|
176
|
+
# If the credential is a hash, transform it prior to setting it
|
177
|
+
credential = Remotus::Auth::Credential.from_hash(credential) unless credential.is_a?(Remotus::Auth::Credential)
|
178
|
+
Remotus::Auth.cache[host] = credential
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|