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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.github/pull_request_template.md +1 -0
- data/.github/workflows/changelog.yml +43 -0
- data/CHANGES.txt +26 -0
- data/CONTRIBUTING.md +35 -0
- data/README.md +58 -50
- data/Rakefile +48 -0
- data/changelog.d/README.md +1 -0
- data/lib/net/ssh/authentication/agent.rb +4 -1
- data/lib/net/ssh/authentication/key_manager.rb +22 -12
- data/lib/net/ssh/authentication/security_key.rb +2 -1
- data/lib/net/ssh/buffer.rb +2 -1
- data/lib/net/ssh/config.rb +6 -0
- data/lib/net/ssh/connection/channel.rb +4 -1
- data/lib/net/ssh/connection/session.rb +7 -4
- data/lib/net/ssh/errors.rb +10 -0
- data/lib/net/ssh/known_hosts.rb +18 -8
- data/lib/net/ssh/multiplex/client.rb +162 -0
- data/lib/net/ssh/multiplex/control_path.rb +120 -0
- data/lib/net/ssh/multiplex/proxy_transport.rb +218 -0
- data/lib/net/ssh/multiplex.rb +130 -0
- data/lib/net/ssh/proxy/jump.rb +40 -2
- data/lib/net/ssh/test.rb +2 -1
- data/lib/net/ssh/transport/algorithms.rb +33 -7
- data/lib/net/ssh/transport/hmac/sha1_etm.rb +12 -0
- data/lib/net/ssh/transport/hmac.rb +2 -0
- data/lib/net/ssh/transport/kex/diffie_hellman_group1_sha1.rb +8 -0
- data/lib/net/ssh/transport/kex/diffie_hellman_group_exchange_sha1.rb +45 -15
- data/lib/net/ssh/transport/openssl.rb +2 -1
- data/lib/net/ssh/transport/session.rb +17 -44
- data/lib/net/ssh/verifiers/always.rb +0 -3
- data/lib/net/ssh/version.rb +1 -1
- data/lib/net/ssh.rb +125 -51
- data/net-ssh.gemspec +1 -1
- data/rakelib/changelog.rb +229 -0
- data.tar.gz.sig +0 -0
- metadata +22 -6
- 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
|
data/lib/net/ssh/proxy/jump.rb
CHANGED
|
@@ -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 #{
|
|
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
|