Skip to content

Commit 8aaa391

Browse files
committed
Provide an alternative implementation of Net::SSH::KnownHost in ssh_options
As discussed in capistrano#326 (comment) Net::SSH re-parse the known_hosts files every time it needs to lookup for a known key. This alternative implementation parse it once and for all, and cache the result.
1 parent 7c4f7fc commit 8aaa391

File tree

7 files changed

+184
-2
lines changed

7 files changed

+184
-2
lines changed

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,19 @@ pooling behaviour entirely by setting the idle_timeout to zero:
508508
SSHKit::Backend::Netssh.pool.idle_timeout = 0 # disabled
509509
```
510510

511+
## Known hosts caching
512+
513+
If you connect to many hosts with the `Netssh` backend, looking up `~/.ssh/known_hosts` can significantly impact performances.
514+
You can mitigate this by using SSHKit's lookup caching like this:
515+
516+
```ruby
517+
SSHKit::Backend::Netssh.configure do |ssh|
518+
ssh.ssh_options = {
519+
known_hosts: SSHKit::Backend::Netssh::KnownHosts.new,
520+
}
521+
end
522+
```
523+
511524
## Tunneling and other related SSH themes
512525

513526
In order to do special gymnasitcs with SSH, tunneling, aliasing, complex options, etc with SSHKit it is possible to use [the underlying Net::SSH API](https://github.com/capistrano/sshkit/blob/master/EXAMPLES.md#setting-global-ssh-options) however in many cases it is preferred to use the system SSH configuration file at [`~/.ssh/config`](http://man.cx/ssh_config). This allows you to have personal configuration tied to your machine that does not have to be committed with the repository. If this is not suitable (everyone on the team needs a proxy command, or some special aliasing) a file in the same format can be placed in the project directory at `~/yourproject/.ssh/config`, this will be merged with the system settings in `~/.ssh/config`, and with any configuration specified in [`SSHKit::Backend::Netssh.config.ssh_options`](https://github.com/capistrano/sshkit/blob/master/lib/sshkit/backends/netssh.rb#L133).

lib/sshkit/all.rb

+1
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,6 @@
3535
require_relative 'backends/connection_pool'
3636
require_relative 'backends/printer'
3737
require_relative 'backends/netssh'
38+
require_relative 'backends/netssh/known_hosts'
3839
require_relative 'backends/local'
3940
require_relative 'backends/skipper'

lib/sshkit/backends/netssh.rb

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
require 'English'
2+
require 'strscan'
3+
require 'mutex_m'
24
require 'net/ssh'
35
require 'net/scp'
46

@@ -21,13 +23,12 @@ module SSHKit
2123
module Backend
2224

2325
class Netssh < Abstract
24-
2526
class Configuration
2627
attr_accessor :connection_timeout, :pty
2728
attr_writer :ssh_options
2829

2930
def ssh_options
30-
@ssh_options || {}
31+
@ssh_options ||= {}
3132
end
3233
end
3334

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
module SSHKit
2+
3+
module Backend
4+
5+
class Netssh < Abstract
6+
7+
class KnownHostsKeys
8+
include Mutex_m
9+
10+
def initialize(path)
11+
super()
12+
@path = File.expand_path(path)
13+
@hosts_keys = nil
14+
end
15+
16+
def keys_for(hostlist)
17+
keys, hashes = hosts_keys, hosts_hashes
18+
parse_file unless keys && hashes
19+
keys, hashes = hosts_keys, hosts_hashes
20+
21+
hostlist.split(',').each do |host|
22+
key_list = keys[host]
23+
return key_list if key_list
24+
25+
hashes.each do |(hmac, salt), hash_keys|
26+
if OpenSSL::HMAC.digest(sha1, salt, host) == hmac
27+
return hash_keys
28+
end
29+
end
30+
end
31+
32+
[]
33+
end
34+
35+
private
36+
37+
attr_reader :path
38+
attr_accessor :hosts_keys, :hosts_hashes
39+
40+
def sha1
41+
@sha1 ||= OpenSSL::Digest.new('sha1')
42+
end
43+
44+
def parse_file
45+
synchronize do
46+
return if hosts_keys && hosts_hashes
47+
48+
unless File.readable?(path)
49+
self.hosts_keys = {}
50+
self.hosts_hashes = []
51+
return
52+
end
53+
54+
new_keys = {}
55+
new_hashes = []
56+
File.open(path) do |file|
57+
scanner = StringScanner.new("")
58+
file.each_line do |line|
59+
scanner.string = line
60+
parse_line(scanner, new_keys, new_hashes)
61+
end
62+
end
63+
self.hosts_keys = new_keys
64+
self.hosts_hashes = new_hashes
65+
end
66+
end
67+
68+
def parse_line(scanner, hosts_keys, hosts_hashes)
69+
return if empty_line?(scanner)
70+
71+
hostlist = parse_hostlist(scanner)
72+
return unless supported_type?(scanner)
73+
key = parse_key(scanner)
74+
75+
if hostlist.size == 1 && hostlist.first =~ /\A\|1(\|.+){2}\z/
76+
hosts_hashes << [parse_host_hash(hostlist.first), key]
77+
else
78+
hostlist.each do |host|
79+
(hosts_keys[host] ||= []) << key
80+
end
81+
end
82+
end
83+
84+
def parse_host_hash(line)
85+
_, _, salt, hmac = line.split('|')
86+
[Base64.decode64(hmac), Base64.decode64(salt)]
87+
end
88+
89+
def empty_line?(scanner)
90+
scanner.skip(/\s*/)
91+
scanner.match?(/$|#/)
92+
end
93+
94+
def parse_hostlist(scanner)
95+
scanner.skip(/\s*/)
96+
scanner.scan(/\S+/).split(',')
97+
end
98+
99+
def supported_type?(scanner)
100+
scanner.skip(/\s*/)
101+
Net::SSH::KnownHosts::SUPPORTED_TYPE.include?(scanner.scan(/\S+/))
102+
end
103+
104+
def parse_key(scanner)
105+
scanner.skip(/\s*/)
106+
Net::SSH::Buffer.new(scanner.rest.unpack("m*").first).read_key
107+
end
108+
end
109+
110+
class KnownHosts
111+
include Mutex_m
112+
113+
def initialize
114+
super()
115+
@files = {}
116+
end
117+
118+
def search_for(host, options = {})
119+
keys = ::Net::SSH::KnownHosts.hostfiles(options).map do |path|
120+
known_hosts_file(path).keys_for(host)
121+
end.flatten
122+
::Net::SSH::HostKeys.new(keys, host, self, options)
123+
end
124+
125+
def add(*args)
126+
::Net::SSH::KnownHosts.add(*args)
127+
synchronize { @files = {} }
128+
end
129+
130+
private
131+
132+
def known_hosts_file(path)
133+
@files[path] || synchronize { @files[path] ||= KnownHostsKeys.new(path) }
134+
end
135+
end
136+
137+
end
138+
139+
end
140+
141+
end

test/known_hosts/github

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==

test/known_hosts/github_hash

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
|1|eKp+6E0rZ3lONgsIziurXEnaIik=|rcQB/rlJMUquUyFta64KugPjX4o= ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==

test/unit/backends/test_netssh.rb

+24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'helper'
2+
require 'tempfile'
23

34
module SSHKit
45
module Backend
@@ -53,6 +54,29 @@ def test_transfer_summarizer
5354
end
5455
end
5556

57+
if Net::SSH::Version::CURRENT >= Net::SSH::Version[3, 1, 0]
58+
def test_known_hosts_for_when_all_hosts_are_recognized
59+
perform_known_hosts_test("github")
60+
end
61+
62+
def test_known_hosts_for_when_an_host_hash_is_recognized
63+
perform_known_hosts_test("github_hash")
64+
end
65+
end
66+
67+
private
68+
69+
def perform_known_hosts_test(hostfile)
70+
source = File.join(File.dirname(__FILE__), '../../known_hosts', hostfile)
71+
kh = Netssh::KnownHosts.new
72+
keys = kh.search_for('github.com', user_known_hosts_file: source, global_known_hosts_file: Tempfile.new('sshkit-test').path)
73+
74+
assert_instance_of ::Net::SSH::HostKeys, keys
75+
assert_equal(1, keys.count)
76+
keys.each do |key|
77+
assert_equal("ssh-rsa", key.ssh_type)
78+
end
79+
end
5680
end
5781
end
5882
end

0 commit comments

Comments
 (0)