net-ssh 7.3.6 → 8.1.2

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 (80) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGES.txt +106 -5
  4. data/README.md +71 -57
  5. data/lib/net/ssh/authentication/agent.rb +35 -11
  6. data/lib/net/ssh/authentication/certificate.rb +2 -2
  7. data/lib/net/ssh/authentication/ed25519.rb +137 -44
  8. data/lib/net/ssh/authentication/ed25519_loader.rb +14 -9
  9. data/lib/net/ssh/authentication/key_manager.rb +22 -11
  10. data/lib/net/ssh/authentication/methods/abstract.rb +6 -8
  11. data/lib/net/ssh/authentication/methods/hostbased.rb +3 -3
  12. data/lib/net/ssh/authentication/methods/publickey.rb +52 -38
  13. data/lib/net/ssh/authentication/security_key.rb +71 -0
  14. data/lib/net/ssh/authentication/session.rb +30 -17
  15. data/lib/net/ssh/buffer.rb +8 -3
  16. data/lib/net/ssh/buffered_io.rb +40 -3
  17. data/lib/net/ssh/config.rb +123 -50
  18. data/lib/net/ssh/connection/channel.rb +49 -2
  19. data/lib/net/ssh/connection/session.rb +151 -13
  20. data/lib/net/ssh/errors.rb +34 -6
  21. data/lib/net/ssh/key_factory.rb +7 -1
  22. data/lib/net/ssh/known_hosts.rb +103 -38
  23. data/lib/net/ssh/multiplex/client.rb +162 -0
  24. data/lib/net/ssh/multiplex/control_path.rb +120 -0
  25. data/lib/net/ssh/multiplex/proxy_transport.rb +218 -0
  26. data/lib/net/ssh/multiplex.rb +127 -0
  27. data/lib/net/ssh/prompt.rb +43 -5
  28. data/lib/net/ssh/proxy/https.rb +28 -4
  29. data/lib/net/ssh/proxy/jump.rb +61 -8
  30. data/lib/net/ssh/service/forward.rb +35 -24
  31. data/lib/net/ssh/test.rb +2 -1
  32. data/lib/net/ssh/transport/algorithms.rb +92 -34
  33. data/lib/net/ssh/transport/chacha20_poly1305_cipher.rb +89 -16
  34. data/lib/net/ssh/transport/chacha20_poly1305_cipher_loader.rb +20 -1
  35. data/lib/net/ssh/transport/cipher_factory.rb +23 -1
  36. data/lib/net/ssh/transport/constants.rb +1 -0
  37. data/lib/net/ssh/transport/gcm_cipher.rb +27 -6
  38. data/lib/net/ssh/transport/hmac/sha1_etm.rb +12 -0
  39. data/lib/net/ssh/transport/hmac.rb +2 -0
  40. data/lib/net/ssh/transport/kex/curve25519_sha256.rb +41 -9
  41. data/lib/net/ssh/transport/kex/curve25519_sha256_loader.rb +14 -6
  42. data/lib/net/ssh/transport/kex/diffie_hellman_group1_sha1.rb +8 -0
  43. data/lib/net/ssh/transport/kex/diffie_hellman_group_exchange_sha1.rb +45 -15
  44. data/lib/net/ssh/transport/openssl.rb +2 -1
  45. data/lib/net/ssh/transport/packet_stream.rb +12 -0
  46. data/lib/net/ssh/transport/session.rb +61 -45
  47. data/lib/net/ssh/verifiers/accept_new_or_local_tunnel.rb +3 -1
  48. data/lib/net/ssh/verifiers/always.rb +16 -4
  49. data/lib/net/ssh/verifiers/never.rb +2 -1
  50. data/lib/net/ssh/version.rb +3 -3
  51. data/lib/net/ssh.rb +164 -63
  52. data.tar.gz.sig +0 -0
  53. metadata +33 -61
  54. metadata.gz.sig +0 -0
  55. data/.dockerignore +0 -9
  56. data/.github/FUNDING.yml +0 -1
  57. data/.github/config/rubocop_linter_action.yml +0 -4
  58. data/.github/workflows/ci-with-docker.yml +0 -58
  59. data/.github/workflows/ci.yml +0 -99
  60. data/.github/workflows/rubocop.yml +0 -16
  61. data/.gitignore +0 -15
  62. data/.rubocop.yml +0 -22
  63. data/.rubocop_todo.yml +0 -1083
  64. data/DEVELOPMENT.md +0 -23
  65. data/Dockerfile +0 -32
  66. data/Dockerfile.openssl3 +0 -17
  67. data/Dockerfile.zlib_ng +0 -29
  68. data/Gemfile +0 -13
  69. data/Gemfile.noed25519 +0 -12
  70. data/Gemfile.norbnacl +0 -12
  71. data/ISSUE_TEMPLATE.md +0 -30
  72. data/Manifest +0 -132
  73. data/Rakefile +0 -196
  74. data/SECURITY.md +0 -4
  75. data/THANKS.txt +0 -110
  76. data/appveyor.yml +0 -58
  77. data/docker-compose.yml +0 -23
  78. data/net-ssh-public_cert.pem +0 -21
  79. data/net-ssh.gemspec +0 -48
  80. data/support/ssh_tunnel_bug.rb +0 -65
@@ -69,27 +69,22 @@ module Net
69
69
 
70
70
  attempted = []
71
71
 
72
- @auth_methods.each do |name|
73
- next unless @allowed_auth_methods.include?(name)
74
-
75
- attempted << name
76
-
77
- debug { "trying #{name}" }
78
- begin
79
- auth_class = Methods.const_get(name.split(/\W+/).map { |p| p.capitalize }.join)
80
- method = auth_class.new(self,
81
- key_manager: key_manager, password_prompt: options[:password_prompt],
82
- pubkey_algorithms: options[:pubkey_algorithms] || nil)
83
- rescue NameError
84
- debug {"Mechanism #{name} was requested, but isn't a known type. Ignoring it."}
85
- next
72
+ # After a partial success (sshd AuthenticationMethods) restart from the most preferred method the server now allows, as OpenSSH does.
73
+ loop do
74
+ @partial_success = false
75
+
76
+ @auth_methods.each do |name|
77
+ next unless @allowed_auth_methods.include?(name)
78
+
79
+ attempted << name
80
+ return true if try_authenticate(name, next_service, username, password, key_manager)
81
+ break if @partial_success
86
82
  end
87
83
 
88
- return true if method.authenticate(next_service, username, password)
89
- rescue Net::SSH::Authentication::DisallowedMethod
84
+ break unless @partial_success
90
85
  end
91
86
 
92
- error { "all authorization methods failed (tried #{attempted.join(', ')})" }
87
+ error { "all authorization methods failed (tried #{attempted.uniq.join(', ')})" }
93
88
  return false
94
89
  ensure
95
90
  key_manager.finish if key_manager
@@ -109,6 +104,7 @@ module Net
109
104
 
110
105
  when USERAUTH_FAILURE
111
106
  @allowed_auth_methods = packet[:authentications].split(/,/)
107
+ @partial_success ||= packet[:partial_success]
112
108
  debug { "allowed methods: #{packet[:authentications]}" }
113
109
  return packet
114
110
 
@@ -136,6 +132,23 @@ module Net
136
132
 
137
133
  private
138
134
 
135
+ def try_authenticate(method_name, next_service, username, password, key_manager)
136
+ debug { "trying #{method_name}" }
137
+ begin
138
+ auth_class = Methods.const_get(method_name.split(/\W+/).map { |p| p.capitalize }.join)
139
+ method = auth_class.new(self,
140
+ key_manager: key_manager, password_prompt: options[:password_prompt],
141
+ fingerprint_hash: options[:fingerprint_hash])
142
+ rescue NameError
143
+ debug {"Mechanism #{method_name} was requested, but isn't a known type. Ignoring it."}
144
+ return false
145
+ end
146
+
147
+ method.authenticate(next_service, username, password)
148
+ rescue Net::SSH::Authentication::DisallowedMethod
149
+ false
150
+ end
151
+
139
152
  # Returns an array of paths to the key files usually defined
140
153
  # by system default.
141
154
  def default_keys
@@ -1,7 +1,9 @@
1
+ require 'net/ssh/errors'
1
2
  require 'net/ssh/transport/openssl'
2
3
 
3
4
  require 'net/ssh/authentication/certificate'
4
5
  require 'net/ssh/authentication/ed25519_loader'
6
+ require 'net/ssh/authentication/security_key'
5
7
 
6
8
  module Net
7
9
  module SSH
@@ -143,7 +145,7 @@ module Net
143
145
  # Appends the given text to the end of the buffer. Does not alter the
144
146
  # read position. Returns the buffer object itself.
145
147
  def append(text)
146
- @content << text
148
+ @content << text.b
147
149
  self
148
150
  end
149
151
 
@@ -338,8 +340,11 @@ module Net
338
340
  key = Net::SSH::Authentication::ED25519::PubKey.read_keyblob(self)
339
341
  when /^ecdsa\-sha2\-(\w*)$/
340
342
  key = OpenSSL::PKey::EC.read_keyblob($1, self)
343
+ when Net::SSH::Authentication::SecurityKey::SK_ECDSA_SHA2_NISTP256,
344
+ Net::SSH::Authentication::SecurityKey::SK_SSH_ED25519
345
+ key = Net::SSH::Authentication::SecurityKey::PubKey.read_keyblob(type, self)
341
346
  else
342
- raise NotImplementedError, "unsupported key type `#{type}'"
347
+ raise Net::SSH::UnsupportedKeyType, "unsupported key type `#{type}'"
343
348
  end
344
349
 
345
350
  return key
@@ -354,7 +359,7 @@ module Net
354
359
  # Writes the given data literally into the string. Does not alter the
355
360
  # read position. Returns the buffer object.
356
361
  def write(*data)
357
- data.each { |datum| @content << datum.dup.force_encoding('BINARY') }
362
+ data.each { |datum| @content << datum.b }
358
363
  self
359
364
  end
360
365
 
@@ -166,6 +166,11 @@ module Net
166
166
  # http://github.com/net-ssh/net-ssh/tree/portfwfix
167
167
  #
168
168
  module ForwardedBufferedIo
169
+ def self.extended(object) # :nodoc:
170
+ object.instance_variable_set(:@on_sent, nil)
171
+ object.instance_variable_set(:@shutdown_write_when_drained, false)
172
+ end
173
+
169
174
  def fill(n = 8192)
170
175
  begin
171
176
  super(n)
@@ -182,11 +187,25 @@ module Net
182
187
  end
183
188
  end
184
189
 
190
+ # Internal and experimental: registers a block called with the number of
191
+ # bytes each #send_pending wrote to the socket.
192
+ def on_sent(&block) # :nodoc:
193
+ @on_sent = block
194
+ end
195
+
185
196
  def send_pending
186
197
  begin
187
- super
188
- rescue Errno::ECONNRESET => e
189
- debug { "connection was reset => shallowing exception:#{e}" }
198
+ pending = output.length
199
+ result = super
200
+ @on_sent.call(pending - output.length) if @on_sent && output.length < pending
201
+ shutdown_write_if_drained
202
+ result
203
+ rescue Errno::EPIPE, Errno::ECONNRESET => e
204
+ # Like OpenSSH's chan_write_failed: the peer is gone, so drop what it
205
+ # will never read and close, letting the forwarded channel close too.
206
+ debug { "write failed => discarding #{output.length} bytes and closing:#{e}" }
207
+ output.clear!
208
+ close
190
209
  return 0
191
210
  rescue IOError => e
192
211
  if e.message =~ /closed/ then
@@ -197,6 +216,24 @@ module Net
197
216
  end
198
217
  end
199
218
  end
219
+
220
+ # Internal: shuts down the write side once pending output has been sent,
221
+ # as OpenSSH does on CHANNEL_EOF (chan_rcvd_ieof, chan_obuf_empty).
222
+ def shutdown_write_when_drained # :nodoc:
223
+ @shutdown_write_when_drained = true
224
+ shutdown_write_if_drained
225
+ end
226
+
227
+ private
228
+
229
+ def shutdown_write_if_drained
230
+ return unless @shutdown_write_when_drained && output.empty? && !closed?
231
+
232
+ @shutdown_write_when_drained = false
233
+ shutdown(Socket::SHUT_WR)
234
+ rescue Errno::EPIPE, Errno::ENOTCONN => e
235
+ debug { "shutdown failed => shallowing exception:#{e}" }
236
+ end
200
237
  end
201
238
  end
202
239
  end
@@ -1,3 +1,6 @@
1
+ require 'etc'
2
+ require 'set'
3
+
1
4
  module Net
2
5
  module SSH
3
6
  # The Net::SSH::Config class is used to parse OpenSSH configuration files,
@@ -15,6 +18,8 @@ module Net
15
18
  # * Compression => :compression
16
19
  # * CompressionLevel => :compression_level
17
20
  # * ConnectTimeout => maps to the :timeout option
21
+ # * ControlPath => :control_path, used only when Net::SSH.start is given
22
+ # control_master: :auto; ControlMaster itself is not read
18
23
  # * ForwardAgent => :forward_agent
19
24
  # * GlobalKnownHostsFile => :global_known_hosts_file
20
25
  # * HostBasedAuthentication => maps to the :auth_methods option
@@ -24,6 +29,7 @@ module Net
24
29
  # * IdentityFile => maps to the :keys option
25
30
  # * IdentityAgent => :identity_agent
26
31
  # * IdentitiesOnly => :keys_only
32
+ # * KexAlgorithms => maps to the :kex option
27
33
  # * CheckHostIP => :check_host_ip
28
34
  # * Macs => maps to the :hmac option
29
35
  # * PasswordAuthentication => maps to the :auth_methods option password
@@ -31,6 +37,7 @@ module Net
31
37
  # * PreferredAuthentications => maps to the :auth_methods option
32
38
  # * ProxyCommand => maps to the :proxy option
33
39
  # * ProxyJump => maps to the :proxy option
40
+ # * PubkeyAcceptedAlgorithms (or PubkeyAcceptedKeyTypes) => maps to :pubkey_algorithms
34
41
  # * PubKeyAuthentication => maps to the :auth_methods option
35
42
  # * RekeyLimit => :rekey_limit
36
43
  # * StrictHostKeyChecking => :verify_host_key
@@ -65,9 +72,19 @@ module Net
65
72
  # given +files+ (defaulting to the list of files returned by
66
73
  # #default_files), translates the resulting hash into the options
67
74
  # recognized by Net::SSH, and returns them.
68
- def for(host, files = expandable_default_files)
75
+ #
76
+ # +user+ is the remote user given explicitly (e.g. to Net::SSH.start),
77
+ # used by <tt>Match User</tt>; when nil, the User set so far by the
78
+ # config, then the local user name, is used, as in OpenSSH.
79
+ #
80
+ # Match supports +all+, +Host+, +OriginalHost+, +User+ and +LocalUser+,
81
+ # each optionally negated with a leading "!". A Match block with any
82
+ # other condition (+exec+, +canonical+, +final+, +localnetwork+,
83
+ # +tagged+, ...) never matches, and a warning naming the condition is
84
+ # printed with Kernel#warn once per config line.
85
+ def for(host, files = expandable_default_files, user: nil)
69
86
  translate(files.inject({}) { |settings, file|
70
- load(file, host, settings)
87
+ load(file, host, settings, user: user)
71
88
  })
72
89
  end
73
90
 
@@ -77,27 +94,26 @@ module Net
77
94
  # ones. Returns a hash containing the OpenSSH options. (See
78
95
  # #translate for how to convert the OpenSSH options into Net::SSH
79
96
  # options.)
80
- def load(path, host, settings = {}, base_dir = nil)
97
+ def load(path, host, settings = {}, base_dir = nil, user: nil)
81
98
  file = File.expand_path(path)
82
99
  base_dir ||= File.dirname(file)
83
100
  return settings unless File.readable?(file)
84
101
 
85
102
  globals = {}
86
- block_matched = false
87
- block_seen = false
103
+ block_matched = block_seen = false
104
+ lineno = 0
88
105
  IO.foreach(file) do |line|
106
+ lineno += 1
89
107
  next if line =~ /^\s*(?:#.*)?$/
90
108
 
91
- if line =~ /^\s*(\S+)\s*=(.*)$/
92
- key, value = $1, $2
93
- else
94
- key, value = line.strip.split(/\s+/, 2)
95
- end
109
+ # As in OpenSSH, the keyword ends at the first whitespace or "=".
110
+ key, value = line.strip.split(/\s*=\s*|\s+/, 2)
96
111
 
97
112
  # silently ignore malformed entries
98
113
  next if value.nil?
99
114
 
100
115
  key.downcase!
116
+ value = uncomment(value)
101
117
  value = unquote(value)
102
118
 
103
119
  value = case value.strip
@@ -125,7 +141,7 @@ module Net
125
141
  block_seen = true
126
142
  settings[key] = host
127
143
  elsif key == 'match'
128
- block_matched = eval_match_conditions(value, host, settings)
144
+ block_matched = eval_match_conditions(value, host, [user, settings['user'], globals['user']].compact.first, "#{file} line #{lineno}")
129
145
  block_seen = true
130
146
  elsif !block_seen
131
147
  case key
@@ -133,7 +149,7 @@ module Net
133
149
  (globals[key] ||= []) << value
134
150
  when 'include'
135
151
  included_file_paths(base_dir, value).each do |file_path|
136
- globals = load(file_path, host, globals, base_dir)
152
+ globals = load(file_path, host, globals, base_dir, user: user)
137
153
  end
138
154
  else
139
155
  globals[key] = value unless settings.key?(key)
@@ -144,7 +160,7 @@ module Net
144
160
  (settings[key] ||= []) << value
145
161
  when 'include'
146
162
  included_file_paths(base_dir, value).each do |file_path|
147
- settings = load(file_path, host, settings, base_dir)
163
+ settings = load(file_path, host, settings, base_dir, user: user)
148
164
  end
149
165
  else
150
166
  settings[key] = value unless settings.key?(key)
@@ -152,9 +168,11 @@ module Net
152
168
  end
153
169
 
154
170
  # ProxyCommand and ProxyJump override each other so they need to be tracked togeather
155
- %w[proxyjump proxycommand].each do |proxy_key|
156
- if (proxy_value = settings.delete(proxy_key))
157
- settings['proxy'] ||= [proxy_key, proxy_value]
171
+ [globals, settings].each do |hash|
172
+ %w[proxyjump proxycommand].each do |proxy_key|
173
+ if (proxy_value = hash.delete(proxy_key))
174
+ hash['proxy'] ||= [proxy_key, proxy_value]
175
+ end
158
176
  end
159
177
  end
160
178
  end
@@ -218,6 +236,7 @@ module Net
218
236
  bindaddress: :bind_address,
219
237
  compression: :compression,
220
238
  compressionlevel: :compression_level,
239
+ controlpath: :control_path,
221
240
  certificatefile: :keycerts,
222
241
  connecttimeout: :timeout,
223
242
  forwardagent: :forward_agent,
@@ -231,7 +250,7 @@ module Net
231
250
  user: :user,
232
251
  checkhostip: :check_host_ip
233
252
  }.freeze
234
- def translate_config_key(hash, key, value, settings)
253
+ def translate_config_key(hash, key, value, settings) # rubocop:disable Metrics/AbcSize
235
254
  case key
236
255
  when :stricthostkeychecking
237
256
  hash[:verify_host_key] = translate_verify_host_key(value)
@@ -247,6 +266,8 @@ module Net
247
266
  hash[:host_key] = value.split(/,/)
248
267
  when :hostname
249
268
  hash[:host_name] = value.gsub(/%h/, settings['host'])
269
+ when :kexalgorithms
270
+ hash[:kex] = value.split(/,/)
250
271
  when :macs
251
272
  hash[:hmac] = value.split(/,/)
252
273
  when :serveralivecountmax
@@ -277,6 +298,8 @@ module Net
277
298
  if (proxy = setup_proxy(*value))
278
299
  hash[:proxy] = proxy
279
300
  end
301
+ when :pubkeyacceptedalgorithms, :pubkeyacceptedkeytypes
302
+ hash[:pubkey_algorithms] = value.split(/,/)
280
303
  when :pubkeyauthentication
281
304
  if value
282
305
  (hash[:auth_methods] << 'publickey').uniq!
@@ -302,19 +325,21 @@ module Net
302
325
  def setup_proxy(type, value)
303
326
  case type
304
327
  when 'proxycommand'
305
- if value !~ /^none$/
328
+ if value !~ /^none$/i
306
329
  require 'net/ssh/proxy/command'
307
330
  Net::SSH::Proxy::Command.new(value)
308
331
  end
309
332
  when 'proxyjump'
310
- require 'net/ssh/proxy/jump'
311
- Net::SSH::Proxy::Jump.new(value)
333
+ if value !~ /^none$/i
334
+ require 'net/ssh/proxy/jump'
335
+ Net::SSH::Proxy::Jump.new(value)
336
+ end
312
337
  end
313
338
  end
314
339
 
315
340
  # Converts an ssh_config pattern into a regex for matching against
316
341
  # host names.
317
- def pattern2regex(pattern)
342
+ def pattern2regex(pattern, ignore_case: true)
318
343
  tail = pattern
319
344
  prefix = String.new
320
345
  while !tail.empty? do
@@ -330,7 +355,7 @@ module Net
330
355
  fail "Unpexpcted sep:#{sep}"
331
356
  end
332
357
  end
333
- Regexp.new("^" + prefix + "$", true)
358
+ Regexp.new("^" + prefix + "$", ignore_case)
334
359
  end
335
360
 
336
361
  # Converts the given size into an integer number of bytes.
@@ -363,39 +388,87 @@ module Net
363
388
  str.scan(/([^"\s]+)?(?:"([^"]+)")?\s*/).map(&:join)
364
389
  end
365
390
 
366
- def eval_match_conditions(condition, host, settings)
391
+ MATCH_CONDITIONS_WITHOUT_ARGUMENT = %w[all canonical final].freeze
392
+ SUPPORTED_MATCH_CONDITIONS = %w[all host originalhost user localuser].freeze
393
+
394
+ def eval_match_conditions(condition, host, remote_user, location)
367
395
  # Not using `\s` for whitespace matching as canonical
368
396
  # ssh_config parser implementation (OpenSSH) has specific character set.
369
397
  # Ref: https://github.com/openssh/openssh-portable/blob/2581333d564d8697837729b3d07d45738eaf5a54/misc.c#L237-L239
370
- conditions = condition.split(/[ \t\r\n]+|(?<!=)=(?!=)/).reject(&:empty?)
371
- return true if conditions == ["all"]
372
-
373
- conditions = conditions.each_slice(2)
374
- condition_matches = []
375
- conditions.each do |(kind, exprs)|
376
- exprs = unquote(exprs)
377
-
378
- case kind.downcase
379
- when "all"
380
- raise "all cannot be mixed with other conditions"
381
- when "host"
382
- if exprs.start_with?('!')
383
- negated = true
384
- exprs = exprs[1..-1]
385
- else
386
- negated = false
387
- end
388
- condition_met = false
389
- exprs.split(",").each do |expr|
390
- condition_met = condition_met || host =~ pattern2regex(expr)
391
- end
392
- condition_matches << (true && negated ^ condition_met)
393
- # else
394
- # warn "net-ssh: Unsupported expr in Match block: #{kind}"
395
- end
398
+ tokens = condition.split(/[ \t\r\n]+|(?<!=)=(?!=)/).reject(&:empty?)
399
+ return true if tokens == ["all"]
400
+
401
+ conditions = parse_match_conditions(tokens)
402
+ unsupported = conditions.find { |_kind, name, exprs|
403
+ !SUPPORTED_MATCH_CONDITIONS.include?(name) || (name != 'all' && exprs.nil?)
404
+ }
405
+ if unsupported
406
+ warn_unsupported_match(unsupported[0], location)
407
+ return false
408
+ end
409
+
410
+ !conditions.empty? && conditions.all? do |kind, name, exprs|
411
+ kind.start_with?('!') ^ match_condition?(name, exprs, host, remote_user || local_user, conditions.size)
412
+ end
413
+ end
414
+
415
+ def parse_match_conditions(tokens)
416
+ conditions = []
417
+ until tokens.empty?
418
+ kind = tokens.shift
419
+ name = kind.delete_prefix('!').downcase
420
+ exprs = tokens.shift unless MATCH_CONDITIONS_WITHOUT_ARGUMENT.include?(name)
421
+ conditions << [kind, name, exprs && unquote(exprs)]
396
422
  end
423
+ conditions
424
+ end
425
+
426
+ def match_condition?(name, exprs, host, remote_user, condition_count)
427
+ case name
428
+ when "all"
429
+ raise "all cannot be mixed with other conditions" if condition_count > 1
430
+
431
+ true
432
+ when "host"
433
+ negated = exprs.start_with?('!')
434
+ exprs = exprs[1..-1] if negated
435
+ negated ^ exprs.split(",").any? { |expr| host =~ pattern2regex(expr) }
436
+ when "originalhost"
437
+ match_pattern_list(host, exprs)
438
+ when "user"
439
+ match_pattern_list(remote_user, exprs, ignore_case: false)
440
+ when "localuser"
441
+ match_pattern_list(local_user, exprs, ignore_case: false)
442
+ end
443
+ end
444
+
445
+ # OpenSSH's match_pattern_list: true if any pattern matches and no "!pattern" does.
446
+ def match_pattern_list(string, list, ignore_case: true)
447
+ matched = false
448
+ list.split(",").each do |pattern|
449
+ negated = pattern.start_with?('!')
450
+ next unless string =~ pattern2regex(negated ? pattern[1..-1] : pattern, ignore_case: ignore_case)
451
+ return false if negated
452
+
453
+ matched = true
454
+ end
455
+ matched
456
+ end
457
+
458
+ def warn_unsupported_match(kind, location)
459
+ @warned_unsupported_matches ||= Set.new
460
+ return unless @warned_unsupported_matches.add?([location, kind])
461
+
462
+ Kernel.warn("net-ssh: #{location}: unsupported Match condition '#{kind}', the Match block is skipped")
463
+ end
464
+
465
+ def local_user
466
+ Etc.getpwuid&.name || Etc.getlogin
467
+ end
397
468
 
398
- !condition_matches.empty? && condition_matches.all?
469
+ # As in OpenSSH's argv_split, "#" starts a comment only at the start of a token and outside quotes.
470
+ def uncomment(string)
471
+ string[/\A(?:\s*(?!#)(?:\\.|"[^"]*"?|'[^']*'?|[^\s"'])+)*/]
399
472
  end
400
473
 
401
474
  def unquote(string)
@@ -55,6 +55,9 @@ module Net
55
55
  include Loggable
56
56
  include Constants
57
57
 
58
+ # OpenSSH's CHAN_SES_WINDOW_DEFAULT; some servers (e.g. Mikrotik) truncate output with smaller windows (#726).
59
+ DEFAULT_MAX_WIN_SIZE = 0x200000
60
+
58
61
  # The local id for this channel, assigned by the Net::SSH::Connection::Session instance.
59
62
  attr_reader :local_id
60
63
 
@@ -109,7 +112,7 @@ module Net
109
112
  # that time (see #do_open_confirmation).
110
113
  #
111
114
  # This also sets the default maximum packet size and maximum window size.
112
- def initialize(connection, type, local_id, max_pkt_size = 0x8000, max_win_size = 0x20000, &on_confirm_open)
115
+ def initialize(connection, type, local_id, max_pkt_size = 0x8000, max_win_size = DEFAULT_MAX_WIN_SIZE, &on_confirm_open)
113
116
  self.logger = connection.logger
114
117
 
115
118
  @connection = connection
@@ -118,6 +121,8 @@ module Net
118
121
 
119
122
  @local_maximum_packet_size = max_pkt_size
120
123
  @local_window_size = @local_maximum_window_size = max_win_size
124
+ @local_consumed = 0
125
+ @grant_window_on_consume = false
121
126
 
122
127
  @on_confirm_open = on_confirm_open
123
128
 
@@ -254,6 +259,26 @@ module Net
254
259
  output.append(data.to_s)
255
260
  end
256
261
 
262
+ # Internal and experimental, used by port forwarding: from now on window
263
+ # is granted only for bytes reported via #consumed, so a slow consumer
264
+ # stalls the remote sender instead of buffering everything it sends.
265
+ # Must be called before any data is received.
266
+ def grant_window_on_consume! # :nodoc:
267
+ raise ArgumentError, "grant_window_on_consume! must be called before any data is received" if local_window_size != local_maximum_window_size
268
+
269
+ @grant_window_on_consume = true
270
+ end
271
+
272
+ # Internal and experimental: reports that +bytes+ of received data have
273
+ # been processed, letting the remote end send that much more.
274
+ def consumed(bytes) # :nodoc:
275
+ raise ArgumentError, "consumed requires grant_window_on_consume!" unless @grant_window_on_consume
276
+ raise ArgumentError, "consumed bytes must not be negative" if bytes.negative?
277
+
278
+ @local_consumed += bytes
279
+ adjust_window_for_consumed
280
+ end
281
+
257
282
  # Returns true if the channel exists in the channel list of the session,
258
283
  # and false otherwise. This can be used to determine whether a channel has
259
284
  # been closed or not.
@@ -598,7 +623,12 @@ module Net
598
623
  # type, and data.
599
624
  def do_extended_data(type, data)
600
625
  update_local_window_size(data.length)
601
- @on_extended_data.call(self, type, data) if @on_extended_data
626
+ if @on_extended_data
627
+ @on_extended_data.call(self, type, data)
628
+ elsif @grant_window_on_consume
629
+ # Nothing will ever consume it, so don't let it shrink the window.
630
+ consumed(data.length)
631
+ end
602
632
  end
603
633
 
604
634
  # Invokes the #on_eof callback when the server indicates that no
@@ -651,6 +681,8 @@ module Net
651
681
  # server telling it that the window size has grown.
652
682
  def update_local_window_size(size)
653
683
  @local_window_size -= size
684
+ return adjust_window_for_consumed if @grant_window_on_consume
685
+
654
686
  if local_window_size < local_maximum_window_size / 2
655
687
  connection.send_message(
656
688
  Buffer.from(:byte, CHANNEL_WINDOW_ADJUST, :long, remote_id, :long, LOCAL_WINDOW_SIZE_INCREMENT)
@@ -663,6 +695,21 @@ module Net
663
695
  end
664
696
  end
665
697
 
698
+ # Mirrors OpenSSH's channel_check_window: once the window is below half,
699
+ # give back what was consumed, growing the maximum window the same way
700
+ # #update_local_window_size does.
701
+ def adjust_window_for_consumed
702
+ return if @local_consumed.zero? || local_window_size >= local_maximum_window_size / 2
703
+ return if remote_id.nil? || local_closed? || remote_closed?
704
+
705
+ growth = (GOOD_LOCAL_MAXIUMUM_WINDOW_SIZE - local_maximum_window_size).clamp(0, LOCAL_WINDOW_SIZE_INCREMENT)
706
+ adjust = @local_consumed + growth
707
+ connection.send_message(Buffer.from(:byte, CHANNEL_WINDOW_ADJUST, :long, remote_id, :long, adjust))
708
+ @local_window_size += adjust
709
+ @local_maximum_window_size += growth
710
+ @local_consumed = 0
711
+ end
712
+
666
713
  # Gets an +Array+ of local environment variables in the remote process'
667
714
  # environment.
668
715
  # A variable name can either be described by a +Regexp+ or +String+.