net-ssh 8.0.0.rc4 → 8.0.0.rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.github/pull_request_template.md +1 -0
  4. data/.github/workflows/changelog.yml +43 -0
  5. data/CHANGES.txt +26 -0
  6. data/CONTRIBUTING.md +35 -0
  7. data/README.md +58 -50
  8. data/Rakefile +48 -0
  9. data/changelog.d/README.md +1 -0
  10. data/lib/net/ssh/authentication/agent.rb +4 -1
  11. data/lib/net/ssh/authentication/key_manager.rb +22 -12
  12. data/lib/net/ssh/authentication/security_key.rb +2 -1
  13. data/lib/net/ssh/buffer.rb +2 -1
  14. data/lib/net/ssh/config.rb +6 -0
  15. data/lib/net/ssh/connection/channel.rb +4 -1
  16. data/lib/net/ssh/connection/session.rb +7 -4
  17. data/lib/net/ssh/errors.rb +10 -0
  18. data/lib/net/ssh/known_hosts.rb +18 -8
  19. data/lib/net/ssh/multiplex/client.rb +162 -0
  20. data/lib/net/ssh/multiplex/control_path.rb +120 -0
  21. data/lib/net/ssh/multiplex/proxy_transport.rb +218 -0
  22. data/lib/net/ssh/multiplex.rb +130 -0
  23. data/lib/net/ssh/proxy/jump.rb +40 -2
  24. data/lib/net/ssh/test.rb +2 -1
  25. data/lib/net/ssh/transport/algorithms.rb +33 -7
  26. data/lib/net/ssh/transport/hmac/sha1_etm.rb +12 -0
  27. data/lib/net/ssh/transport/hmac.rb +2 -0
  28. data/lib/net/ssh/transport/kex/diffie_hellman_group1_sha1.rb +8 -0
  29. data/lib/net/ssh/transport/kex/diffie_hellman_group_exchange_sha1.rb +45 -15
  30. data/lib/net/ssh/transport/openssl.rb +2 -1
  31. data/lib/net/ssh/transport/session.rb +17 -44
  32. data/lib/net/ssh/verifiers/always.rb +0 -3
  33. data/lib/net/ssh/version.rb +1 -1
  34. data/lib/net/ssh.rb +125 -51
  35. data/net-ssh.gemspec +1 -1
  36. data/rakelib/changelog.rb +229 -0
  37. data.tar.gz.sig +0 -0
  38. metadata +22 -6
  39. metadata.gz.sig +0 -0
@@ -0,0 +1,162 @@
1
+ require 'io/wait'
2
+ require 'socket'
3
+
4
+ require 'net/ssh/buffer'
5
+ require 'net/ssh/errors'
6
+ require 'net/ssh/loggable'
7
+
8
+ module Net
9
+ module SSH
10
+ module Multiplex
11
+ # Raised when the ControlMaster socket can't be used, e.g. the master
12
+ # refused the request or spoke an unexpected protocol.
13
+ class Error < Net::SSH::Exception; end
14
+
15
+ # Connects to an OpenSSH ControlMaster's UNIX socket and switches it to
16
+ # proxy mode (MUX_C_PROXY, OpenSSH 7.4 and later), after which the socket
17
+ # carries plain SSH connection-protocol packets. See PROTOCOL.mux and
18
+ # mux.c in OpenSSH.
19
+ class Client
20
+ include Loggable
21
+
22
+ MUX_MSG_HELLO = 0x00000001
23
+ MUX_C_PROXY = 0x1000000f
24
+ MUX_S_PERMISSION_DENIED = 0x80000002
25
+ MUX_S_FAILURE = 0x80000003
26
+ MUX_S_PROXY = 0x8000000f
27
+
28
+ PROTOCOL_VERSION = 4
29
+
30
+ # Mux control messages are small; this only guards against reading a
31
+ # bogus length from something that isn't a ControlMaster.
32
+ MAX_MESSAGE_SIZE = 256 * 1024
33
+
34
+ attr_reader :path
35
+
36
+ def initialize(path, timeout:, logger: nil)
37
+ self.logger = logger
38
+ @path = path
39
+ @timeout = timeout
40
+ end
41
+
42
+ # Performs the HELLO and PROXY exchange and returns the connected
43
+ # socket, positioned at the first proxied packet. Raises
44
+ # Multiplex::Error, SystemCallError or IOError if the master can't be
45
+ # used; the socket is closed in that case.
46
+ def connect_proxy
47
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
48
+ @socket = open_socket
49
+ verify_peer
50
+ hello
51
+ request_proxy
52
+ @socket
53
+ rescue StandardError
54
+ @socket&.close
55
+ raise
56
+ end
57
+
58
+ private
59
+
60
+ def open_socket
61
+ socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM)
62
+ address = Socket.sockaddr_un(path)
63
+ loop do
64
+ result = socket.connect_nonblock(address, exception: false)
65
+ break if result == 0
66
+
67
+ wait_for(:writable, socket)
68
+ rescue Errno::EISCONN
69
+ break
70
+ end
71
+ socket
72
+ rescue StandardError
73
+ socket&.close
74
+ raise
75
+ end
76
+
77
+ # The master itself rejects clients with a different uid (channels.c,
78
+ # getpeereid); checking here also keeps us off a socket someone else
79
+ # planted at the ControlPath.
80
+ def verify_peer
81
+ euid = begin
82
+ @socket.getpeereid.first
83
+ rescue NotImplementedError
84
+ File.stat(path).uid
85
+ end
86
+ raise Error, "#{path} is owned by uid #{euid}, not #{Process.euid}" unless euid == Process.euid
87
+ end
88
+
89
+ def hello
90
+ type, body = read_message
91
+ raise Error, "expected MUX_MSG_HELLO, got 0x#{type.to_s(16)}" unless type == MUX_MSG_HELLO
92
+
93
+ version = body.read_long
94
+ raise Error, "unsupported mux protocol version #{version}" unless version == PROTOCOL_VERSION
95
+
96
+ # Any extensions the master advertises (OpenSSH 10.3 sends "info")
97
+ # are optional, so the rest of the body is ignored.
98
+ write_message(MUX_MSG_HELLO, :long, PROTOCOL_VERSION)
99
+ debug { "mux hello exchanged with #{path}" }
100
+ end
101
+
102
+ def request_proxy
103
+ request_id = 1
104
+ write_message(MUX_C_PROXY, :long, request_id)
105
+ type, body = read_message
106
+ case type
107
+ when MUX_S_PROXY
108
+ reply_id = body.read_long
109
+ raise Error, "MUX_S_PROXY for request #{reply_id}, expected #{request_id}" unless reply_id == request_id
110
+ when MUX_S_PERMISSION_DENIED, MUX_S_FAILURE
111
+ body.read_long
112
+ raise Error, "master refused proxy request: #{body.read_string}"
113
+ else
114
+ raise Error, "unexpected reply 0x#{type.to_s(16)} to MUX_C_PROXY"
115
+ end
116
+ end
117
+
118
+ def write_message(type, *args)
119
+ body = Net::SSH::Buffer.from(:long, type, *args).to_s
120
+ data = [body.bytesize].pack('N') + body
121
+ until data.empty?
122
+ written = @socket.write_nonblock(data, exception: false)
123
+ if written == :wait_writable
124
+ wait_for(:writable, @socket)
125
+ else
126
+ data = data.byteslice(written..-1)
127
+ end
128
+ end
129
+ end
130
+
131
+ def read_message
132
+ length = read_exactly(4).unpack1('N')
133
+ raise Error, "mux message length #{length} out of range" unless length.between?(4, MAX_MESSAGE_SIZE)
134
+
135
+ body = Net::SSH::Buffer.new(read_exactly(length))
136
+ [body.read_long, body]
137
+ end
138
+
139
+ # Reads exactly +count+ bytes so nothing after the PROXY reply, which
140
+ # already belongs to the packet stream, is consumed here.
141
+ def read_exactly(count)
142
+ data = String.new(encoding: Encoding::BINARY)
143
+ while data.bytesize < count
144
+ chunk = @socket.read_nonblock(count - data.bytesize, exception: false)
145
+ case chunk
146
+ when :wait_readable then wait_for(:readable, @socket)
147
+ when nil then raise Error, "#{path} closed the connection during the mux handshake"
148
+ else data << chunk
149
+ end
150
+ end
151
+ data
152
+ end
153
+
154
+ def wait_for(direction, socket)
155
+ remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ ready = remaining > 0 && (direction == :readable ? socket.wait_readable(remaining) : socket.wait_writable(remaining))
157
+ raise Error, "timed out talking to #{path}" unless ready
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,120 @@
1
+ require 'digest/sha1'
2
+ require 'ipaddr'
3
+ require 'etc'
4
+ require 'socket'
5
+
6
+ module Net
7
+ module SSH
8
+ module Multiplex
9
+ # Expands a ControlPath the way OpenSSH's ssh does: a leading ~ first,
10
+ # then ${ENV} and the % tokens below in a single pass (ssh.c,
11
+ # DEFAULT_CLIENT_PERCENT_EXPAND_ARGS in sshconnect.h, misc.c
12
+ # percent_dollar_expand).
13
+ #
14
+ # %% a literal %
15
+ # %C SHA1 hex of %l%h%p%r%j
16
+ # %d local home directory
17
+ # %h remote host name, after HostName
18
+ # %i local uid
19
+ # %j host of the last ProxyJump hop, or empty
20
+ # %k the host key alias, or the host name as given
21
+ # %L local host name up to the first dot
22
+ # %l local host name
23
+ # %n the host name as given
24
+ # %p remote port
25
+ # %r remote user
26
+ # %u local user
27
+ class ControlPath
28
+ # +host_arg+ is the host as given, +host+ the one connected to after
29
+ # HostName.
30
+ def initialize(host_arg:, host:, port:, remote_user:, jump_host: '', host_key_alias: nil) # rubocop:disable Metrics/ParameterLists
31
+ @host_arg = host_arg.to_s
32
+ # ssh lowercases host names, but not addresses, before expanding.
33
+ @host = address?(host.to_s) ? host.to_s : host.to_s.downcase
34
+ @port = port.to_s
35
+ @remote_user = remote_user.to_s
36
+ @jump_host = jump_host.to_s
37
+ @host_key_alias = host_key_alias
38
+ end
39
+
40
+ # Raises ArgumentError for an unknown % token, an unset ${ENV}
41
+ # variable or an unknown ~user, which make ssh refuse the ControlPath
42
+ # too.
43
+ def expand(path)
44
+ expand_tokens(expand_tilde(path))
45
+ end
46
+
47
+ def tokens
48
+ @tokens ||= begin
49
+ pw = Etc.getpwuid(Process.uid)
50
+ local_host = Socket.gethostname
51
+ {
52
+ 'C' => Digest::SHA1.hexdigest("#{local_host}#{@host}#{@port}#{@remote_user}#{@jump_host}"),
53
+ 'd' => pw.dir,
54
+ 'h' => @host,
55
+ 'i' => Process.uid.to_s,
56
+ 'j' => @jump_host,
57
+ 'k' => @host_key_alias || @host_arg,
58
+ 'L' => local_host.split('.', 2).first,
59
+ 'l' => local_host,
60
+ 'n' => @host_arg,
61
+ 'p' => @port,
62
+ 'r' => @remote_user,
63
+ 'u' => pw.name
64
+ }
65
+ end
66
+ end
67
+
68
+ # The host ssh uses for %j: the last hop of a ProxyJump list, without
69
+ # user or port (readconf.c, parse_jump).
70
+ def self.jump_host(proxy_jump)
71
+ last = proxy_jump.to_s.split(',').last.to_s.strip.delete_prefix('ssh://')
72
+ last = last.split('@').last.to_s
73
+ if last.start_with?('[')
74
+ last[1...last.index(']').to_i]
75
+ else
76
+ last.split(':').first.to_s
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # ssh uses the home directory from the password database, not $HOME
83
+ # (misc.c, tilde_expand).
84
+ def expand_tilde(path)
85
+ return path unless path.start_with?('~')
86
+
87
+ user, rest = path[1..].split('/', 2)
88
+ home = user.nil? || user.empty? ? Etc.getpwuid(Process.uid).dir : Etc.getpwnam(user).dir
89
+
90
+ rest.nil? ? home : File.join(home, rest)
91
+ end
92
+
93
+ def expand_tokens(path)
94
+ path.gsub(/\$\{([^}]*)\}|%(.?)|\$\{.*/m) do
95
+ if Regexp.last_match(1)
96
+ name = Regexp.last_match(1)
97
+ raise ArgumentError, "empty ${} variable" if name.empty?
98
+
99
+ ENV.fetch(name) { raise ArgumentError, "variable ${#{name}} is not set" }
100
+ elsif Regexp.last_match(2)
101
+ token = Regexp.last_match(2)
102
+ next '%' if token == '%'
103
+
104
+ tokens.fetch(token) { raise ArgumentError, "unknown token %#{token}" }
105
+ else
106
+ raise ArgumentError, "unterminated ${"
107
+ end
108
+ end
109
+ end
110
+
111
+ def address?(host)
112
+ IPAddr.new(host)
113
+ true
114
+ rescue IPAddr::Error
115
+ false
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,218 @@
1
+ require 'io/wait'
2
+
3
+ require 'net/ssh/buffer'
4
+ require 'net/ssh/buffered_io'
5
+ require 'net/ssh/errors'
6
+ require 'net/ssh/loggable'
7
+ require 'net/ssh/packet'
8
+ require 'net/ssh/connection/constants'
9
+
10
+ module Net
11
+ module SSH
12
+ module Multiplex
13
+ # Packet framing on a ControlMaster socket in proxy mode: the usual
14
+ # uint32 packet_length and byte padding_length, but no padding, MAC,
15
+ # encryption or compression. The master parses everything after the
16
+ # padding_length byte as payload (channel_proxy_downstream in OpenSSH's
17
+ # channels.c), so padding must not be sent.
18
+ module PacketSocket
19
+ include BufferedIo
20
+
21
+ # OpenSSH's PACKET_MAX_SIZE.
22
+ MAX_PACKET_SIZE = 256 * 1024
23
+
24
+ def self.extended(object) # :nodoc:
25
+ object.__send__(:initialize_buffered_io)
26
+ end
27
+
28
+ def available_for_read?
29
+ !wait_readable(0).nil?
30
+ end
31
+
32
+ # Same contract as Net::SSH::Transport::PacketStream#next_packet.
33
+ def next_packet(mode = :nonblock, timeout = nil)
34
+ case mode
35
+ when :nonblock
36
+ packet = poll_next_packet
37
+ return packet if packet
38
+
39
+ if available_for_read? && fill <= 0
40
+ packet = poll_next_packet
41
+ raise Net::SSH::Disconnect, "connection closed by ControlMaster" if packet.nil?
42
+
43
+ return packet
44
+ end
45
+ poll_next_packet
46
+ when :block
47
+ loop do
48
+ packet = poll_next_packet
49
+ return packet if packet
50
+
51
+ raise Net::SSH::ConnectionTimeout, "timeout waiting for next packet" unless wait_readable(timeout)
52
+ raise Net::SSH::Disconnect, "connection closed by ControlMaster" if fill <= 0
53
+ end
54
+ else
55
+ raise ArgumentError, "expected :block or :nonblock, got #{mode.inspect}"
56
+ end
57
+ end
58
+
59
+ def enqueue_packet(payload)
60
+ data = payload.to_s
61
+ enqueue([data.bytesize + 1, 0].pack('NC') + data.b)
62
+ end
63
+
64
+ def send_packet(payload)
65
+ enqueue_packet(payload)
66
+ wait_for_pending_sends
67
+ end
68
+
69
+ private
70
+
71
+ def poll_next_packet
72
+ return nil if available < 4
73
+
74
+ start = input.position
75
+ length = input.read_long
76
+ raise Net::SSH::Exception, "invalid packet length #{length} from ControlMaster" unless length.between?(1, MAX_PACKET_SIZE)
77
+
78
+ if available < length
79
+ input.position = start
80
+ return nil
81
+ end
82
+
83
+ data = input.read(length)
84
+ input.consume!
85
+ padding = data.getbyte(0)
86
+ raise Net::SSH::Exception, "invalid padding length #{padding} from ControlMaster" if padding > length - 2
87
+
88
+ Net::SSH::Packet.new(data.byteslice(1, length - 1 - padding))
89
+ end
90
+ end
91
+
92
+ # Stands in for Net::SSH::Transport::Session underneath a
93
+ # Net::SSH::Connection::Session when connected through an OpenSSH
94
+ # ControlMaster in proxy mode. Key exchange, rekeying, host key
95
+ # verification and authentication all belong to the master, so the
96
+ # matching transport methods are no-ops or raise
97
+ # NotSupportedOverControlMaster.
98
+ class ProxyTransport
99
+ include Loggable
100
+ include Net::SSH::Connection::Constants
101
+
102
+ DEFAULT_PORT = 22
103
+
104
+ # Channel requests whose follow-up channel the server opens towards
105
+ # the master, which serves it from its own agent or X display only if
106
+ # it has ForwardAgent or ForwardX11 enabled itself, and rejects it
107
+ # otherwise (clientloop.c, client_input_channel_open).
108
+ UNSUPPORTED_CHANNEL_REQUESTS = %w[auth-agent-req@openssh.com auth-agent-req x11-req].freeze
109
+
110
+ attr_reader :host, :port, :socket, :options
111
+
112
+ # The ControlMaster socket path this transport is connected through.
113
+ attr_reader :control_path
114
+
115
+ def initialize(socket, host:, control_path:, options: {})
116
+ self.logger = options[:logger]
117
+ @host = host
118
+ @port = options[:port] || DEFAULT_PORT
119
+ @options = options
120
+ @control_path = control_path
121
+ @socket = socket
122
+ @socket.extend(PacketSocket)
123
+ @socket.logger = logger
124
+ end
125
+
126
+ def host_as_string
127
+ @host_as_string ||= port == DEFAULT_PORT ? host.to_s : "[#{host}]:#{port}"
128
+ end
129
+
130
+ def peer
131
+ @peer ||= { ip: nil, port: port.to_i, host: host, canonized: host_as_string }
132
+ end
133
+
134
+ def closed?
135
+ socket.closed?
136
+ end
137
+
138
+ def close
139
+ socket.close unless closed?
140
+ end
141
+
142
+ def shutdown!
143
+ error { "forcing connection closed" }
144
+ close
145
+ end
146
+
147
+ def next_message
148
+ poll_message(:block)
149
+ end
150
+
151
+ def poll_message(mode = :nonblock, *)
152
+ socket.next_packet(mode, options[:timeout])
153
+ end
154
+
155
+ def send_message(message)
156
+ socket.send_packet(check_supported(message.to_s))
157
+ end
158
+
159
+ def enqueue_message(message)
160
+ socket.enqueue_packet(check_supported(message.to_s))
161
+ end
162
+
163
+ # The master rekeys its own connection.
164
+ def rekey!; end
165
+
166
+ def rekey_as_needed; end
167
+
168
+ def hint(*); end
169
+
170
+ def server_version
171
+ not_supported("the server version")
172
+ end
173
+
174
+ def algorithms
175
+ not_supported("the negotiated algorithms")
176
+ end
177
+
178
+ def host_keys
179
+ not_supported("the host keys")
180
+ end
181
+
182
+ def host_key_verifier
183
+ not_supported("host key verification")
184
+ end
185
+
186
+ private
187
+
188
+ def not_supported(what)
189
+ raise NotSupportedOverControlMaster, "#{what} belong(s) to the ControlMaster at #{control_path}"
190
+ end
191
+
192
+ # The master kills the proxy connection on any global request other
193
+ # than tcpip-forward, and consumes the reply to that one itself, so
194
+ # its success or failure and an allocated port 0 can't be observed
195
+ # (channels.c channel_proxy_downstream, clientloop.c
196
+ # client_global_request_reply).
197
+ def check_supported(data)
198
+ case data.getbyte(0)
199
+ when GLOBAL_REQUEST
200
+ request = Net::SSH::Buffer.new(data.byteslice(1..-1)).read_string
201
+ if request == 'tcpip-forward'
202
+ raise NotSupportedOverControlMaster, "remote forwarding (tcpip-forward) is not supported over a ControlMaster: " \
203
+ "the master consumes the reply, so success and the allocated port can't be observed"
204
+ end
205
+
206
+ raise NotSupportedOverControlMaster, "global request #{request} is not supported over a ControlMaster"
207
+ when CHANNEL_REQUEST
208
+ request = Net::SSH::Buffer.new(data.byteslice(5..-1)).read_string
209
+ if UNSUPPORTED_CHANNEL_REQUESTS.include?(request)
210
+ raise NotSupportedOverControlMaster, "channel request #{request} is not supported over a ControlMaster"
211
+ end
212
+ end
213
+ data
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,130 @@
1
+ require 'socket'
2
+
3
+ require 'net/ssh/errors'
4
+ require 'net/ssh/connection/session'
5
+ require 'net/ssh/multiplex/client'
6
+ require 'net/ssh/multiplex/control_path'
7
+ require 'net/ssh/multiplex/proxy_transport'
8
+
9
+ module Net
10
+ module SSH
11
+ # Reuse of an existing OpenSSH ControlMaster connection, enabled by the
12
+ # :control_master option of Net::SSH.start. Net::SSH only ever connects to
13
+ # a master someone else started (e.g. with ssh -M or ControlMaster auto);
14
+ # it never becomes one.
15
+ module Multiplex
16
+ # Used for the mux handshake when the :timeout option isn't set.
17
+ DEFAULT_TIMEOUT = 5
18
+
19
+ # Options for setting up a connection, which the master already did
20
+ # with its own settings.
21
+ MASTER_OWNED_OPTIONS = %i[
22
+ agent_socket_factory append_all_supported_algorithms auth_methods
23
+ bind_address check_host_ip compression compression_level encryption
24
+ fingerprint_hash global_known_hosts_file hmac host_key identity_agent
25
+ keepalive keepalive_interval keepalive_maxcount kex key_data keycert_data
26
+ keycerts keys keys_only known_hosts languages minimum_dh_bits no_delay
27
+ non_interactive number_of_password_prompts passphrase password
28
+ password_prompt proxy pubkey_algorithms rekey_blocks_limit rekey_limit
29
+ rekey_packet_limit use_agent user_known_hosts_file verify_host_key
30
+ ].freeze
31
+
32
+ VALUES = [:auto, false, nil].freeze
33
+
34
+ def self.validate_options!(options)
35
+ unless VALUES.include?(options[:control_master])
36
+ raise ArgumentError, "control_master must be :auto or false, got #{options[:control_master].inspect}"
37
+ end
38
+ return unless options[:control_master] && options[:forward_agent]
39
+
40
+ raise ArgumentError, "forward_agent can't be used with control_master: the ControlMaster serves agent requests " \
41
+ "from its own agent only if it has ForwardAgent enabled itself, and rejects them otherwise"
42
+ end
43
+
44
+ # Returns a Connection::Session running through the ControlMaster for
45
+ # this connection, or nil (after logging why) when there is no usable
46
+ # master, in which case the caller connects as usual. +explicit+ are
47
+ # the option names the caller passed to Net::SSH.start. Raises
48
+ # ArgumentError for an invalid ControlPath, as ssh does.
49
+ def self.connect(host_arg, host, user, options, explicit: [])
50
+ logger = options[:logger]
51
+ path = socket_path(host_arg, host, user, options)
52
+ return nil unless path
53
+
54
+ socket = open_proxy(path, options)
55
+ return nil unless socket
56
+
57
+ logger.info { "control_master: using ControlMaster at #{path}" }
58
+ warn_master_owned(logger, explicit)
59
+ begin
60
+ transport = ProxyTransport.new(socket, host: host, control_path: path, options: options)
61
+ Connection::Session.new(transport, session_options(logger, options))
62
+ rescue StandardError
63
+ socket.close
64
+ raise
65
+ end
66
+ end
67
+
68
+ # ENOENT and ECONNREFUSED just mean no master is running, while the
69
+ # other errors mean a socket is there but can't be used.
70
+ def self.open_proxy(path, options)
71
+ Client.new(path, timeout: options[:timeout] || DEFAULT_TIMEOUT, logger: options[:logger]).connect_proxy
72
+ rescue Errno::ENOENT, Errno::ECONNREFUSED => e
73
+ options[:logger].info { "control_master: no ControlMaster at #{path}: #{e.message} (#{e.class})" }
74
+ nil
75
+ rescue Multiplex::Error, SystemCallError, IOError, SocketError => e
76
+ options[:logger].warn { "control_master: not using ControlMaster at #{path}: #{e.message} (#{e.class})" }
77
+ nil
78
+ end
79
+
80
+ def self.socket_path(host_arg, host, user, options)
81
+ if Gem.win_platform? || !defined?(UNIXSocket)
82
+ options[:logger].debug { "control_master: UNIX sockets are not available on this platform" }
83
+ return nil
84
+ end
85
+
86
+ control_path = options[:control_path]
87
+ if control_path.nil? || control_path.to_s == 'none'
88
+ options[:logger].debug { "control_master: no ControlPath set" }
89
+ return nil
90
+ end
91
+
92
+ expander = ControlPath.new(
93
+ host_arg: host_arg, host: host, port: options[:port] || ProxyTransport::DEFAULT_PORT, remote_user: user,
94
+ jump_host: jump_host(options[:proxy]), host_key_alias: options[:host_key_alias]
95
+ )
96
+ begin
97
+ path = expander.expand(control_path.to_s)
98
+ Socket.sockaddr_un(path)
99
+ rescue ArgumentError => e
100
+ raise ArgumentError, "invalid control_path #{control_path.to_s.inspect}: #{e.message}"
101
+ end
102
+ path
103
+ end
104
+
105
+ def self.jump_host(proxy)
106
+ return '' unless defined?(Net::SSH::Proxy::Jump) && proxy.is_a?(Net::SSH::Proxy::Jump)
107
+
108
+ ControlPath.jump_host(proxy.jump_proxies)
109
+ end
110
+
111
+ def self.warn_master_owned(logger, explicit)
112
+ bypassed = explicit & MASTER_OWNED_OPTIONS
113
+ return if bypassed.empty?
114
+
115
+ logger.warn { "control_master: ignoring #{bypassed.join(', ')}, the ControlMaster's own settings apply" }
116
+ end
117
+
118
+ # Keepalive is a global request, which the master doesn't allow; it
119
+ # watches its own connection anyway. ForwardAgent from ssh_config is
120
+ # dropped for the reason given in validate_options!.
121
+ def self.session_options(logger, options)
122
+ logger.info { "control_master: keepalive is left to the ControlMaster" } if options[:keepalive]
123
+ logger.info { "control_master: ignoring ForwardAgent, the ControlMaster's own ForwardAgent setting applies" } if options[:forward_agent]
124
+ options.merge(keepalive: false, forward_agent: false)
125
+ end
126
+
127
+ private_class_method :open_proxy, :socket_path, :jump_host, :warn_master_owned, :session_options
128
+ end
129
+ end
130
+ end
@@ -15,14 +15,33 @@ module Net
15
15
  # Net::SSH.start('host', 'user', :proxy => proxy) do |ssh|
16
16
  # ...
17
17
  # end
18
+ #
19
+ # ssh_config options for the jump host can be given as a Hash via
20
+ # +options:+; each is passed to the ssh command as +-o Key=value+:
21
+ #
22
+ # proxy = Net::SSH::Proxy::Jump.new('user@proxy', options: { 'StrictHostKeyChecking' => 'no' })
23
+ #
24
+ # Options that ssh_config allows more than once (+IdentityFile+,
25
+ # +CertificateFile+ and +SendEnv+) take an Array and are passed once per
26
+ # element; an empty Array passes nothing:
27
+ #
28
+ # proxy = Net::SSH::Proxy::Jump.new('user@proxy', options: { 'IdentityFile' => ['~/.ssh/jump', '~/.ssh/jump_old'] })
29
+ #
30
+ # A +nil+ value, alone or in an Array, raises ArgumentError, as it does
31
+ # for the options passed to Net::SSH.start; omit the option instead.
32
+ #
33
+ # The options apply to the first jump host only; any further hosts in
34
+ # the chain are connected to by that host's own ssh.
18
35
  class Jump < Command
19
36
  # The jump proxies
20
37
  attr_reader :jump_proxies
21
38
 
22
39
  # Create a new socket factory that tunnels via multiple jump proxes as
23
40
  # [user@]host[:port].
24
- def initialize(jump_proxies)
41
+ def initialize(jump_proxies, options: {})
25
42
  @jump_proxies = jump_proxies
43
+ @options = options
44
+ validate_options!
26
45
  end
27
46
 
28
47
  # Return a new socket connected to the given host and port via the jump
@@ -43,7 +62,10 @@ module Net
43
62
  template << " -l #{uri.user}" if uri.user
44
63
  template << " -p #{uri.port}" if uri.port
45
64
  template << " -J #{extra_jumps}" if extra_jumps
46
- template << " -F #{Shellwords.escape(config)}" if config != true && config
65
+ template << " -F #{escape(config)}" if config != true && config
66
+ @options.each do |key, values|
67
+ Array(values).each { |value| template << " -o #{escape(key)}=#{escape(value)}" }
68
+ end
47
69
  template << " -W %h:%p "
48
70
  template << uri.host
49
71
 
@@ -52,6 +74,22 @@ module Net
52
74
 
53
75
  private
54
76
 
77
+ # Like Net::SSH.start, reject nil option values (alone or in an Array)
78
+ # rather than silently passing nothing or an empty value to ssh.
79
+ def validate_options!
80
+ nil_keys = @options.select { |_key, value| value.nil? || (value.is_a?(Array) && value.include?(nil)) }.keys
81
+ return if nil_keys.empty?
82
+
83
+ raise ArgumentError, "nil is not allowed for #{nil_keys.map(&:inspect).join(', ')} in Net::SSH::Proxy::Jump options; " \
84
+ "omit the option to use the default or ssh_config value"
85
+ end
86
+
87
+ # Escape for the shell, and double any % so it survives the %h/%p
88
+ # substitution done on the template in Command#open.
89
+ def escape(value)
90
+ Shellwords.escape(value).gsub("%", "%%")
91
+ end
92
+
55
93
  def validate_jump!(jump)
56
94
  uri = URI.parse("ssh://#{jump}")
57
95
  validate_user_name!(uri.user) if uri.user