summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorelijah <elijah@riseup.net>2014-11-05 15:44:24 -0800
committerelijah <elijah@riseup.net>2014-11-05 15:44:24 -0800
commit2c697c574a6844c6cec3dc0cb6498cc0f87ff072 (patch)
tree7bac92e321c42f5147c481afd5905b317cc52768 /lib
parent1ba5abc1a9359a00cee2da06b9766eb0bdda9f29 (diff)
prompt user to update ssh host keys when a better one is available. closes #6320
Diffstat (limited to 'lib')
-rw-r--r--lib/leap_cli/commands/compile.rb24
-rw-r--r--lib/leap_cli/commands/node.rb155
-rw-r--r--lib/leap_cli/commands/node_init.rb167
-rw-r--r--lib/leap_cli/ssh_key.rb46
4 files changed, 241 insertions, 151 deletions
diff --git a/lib/leap_cli/commands/compile.rb b/lib/leap_cli/commands/compile.rb
index 644ce2a..b30aaea 100644
--- a/lib/leap_cli/commands/compile.rb
+++ b/lib/leap_cli/commands/compile.rb
@@ -98,6 +98,30 @@ module LeapCli
write_file!(:authorized_keys, buffer.string)
end
+ #
+ # generates the known_hosts file.
+ #
+ # we do a 'late' binding on the hostnames and ip part of the ssh pub key record in order to allow
+ # for the possibility that the hostnames or ip has changed in the node configuration.
+ #
+ def update_known_hosts
+ buffer = StringIO.new
+ buffer << "#\n"
+ buffer << "# This file is automatically generated by the command `leap`. You should NOT modify this file.\n"
+ buffer << "# Instead, rerun `leap node init` on whatever node is causing SSH problems.\n"
+ buffer << "#\n"
+ manager.nodes.keys.sort.each do |node_name|
+ node = manager.nodes[node_name]
+ hostnames = [node.name, node.domain.internal, node.domain.full, node.ip_address].join(',')
+ pub_key = read_file([:node_ssh_pub_key,node.name])
+ if pub_key
+ buffer << [hostnames, pub_key].join(' ')
+ buffer << "\n"
+ end
+ end
+ write_file!(:known_hosts, buffer.string)
+ end
+
##
## ZONE FILE
##
diff --git a/lib/leap_cli/commands/node.rb b/lib/leap_cli/commands/node.rb
index 190d348..6709077 100644
--- a/lib/leap_cli/commands/node.rb
+++ b/lib/leap_cli/commands/node.rb
@@ -1,3 +1,8 @@
+#
+# fyi: the `node init` command lives in node_init.rb,
+# but all other `node x` commands live here.
+#
+
autoload :IPAddr, 'ipaddr'
module LeapCli; module Commands
@@ -42,45 +47,6 @@ module LeapCli; module Commands
end
end
- node.desc 'Bootstraps a node or nodes, setting up SSH keys and installing prerequisite packages'
- node.long_desc "This command prepares a server to be used with the LEAP Platform by saving the server's SSH host key, " +
- "copying the authorized_keys file, installing packages that are required for deploying, and registering important facts. " +
- "Node init must be run before deploying to a server, and the server must be running and available via the network. " +
- "This command only needs to be run once, but there is no harm in running it multiple times."
- node.arg_name 'FILTER' #, :optional => false, :multiple => false
- node.command :init do |init|
- init.switch 'echo', :desc => 'If set, passwords are visible as you type them (default is hidden)', :negatable => false
- init.flag :port, :desc => 'Override the default SSH port.', :arg_name => 'PORT'
- init.flag :ip, :desc => 'Override the default SSH IP address.', :arg_name => 'IPADDRESS'
-
- init.action do |global,options,args|
- assert! args.any?, 'You must specify a FILTER'
- finished = []
- manager.filter!(args).each_node do |node|
- is_node_alive(node, options)
- save_public_host_key(node, global, options) unless node.vagrant?
- update_compiled_ssh_configs
- ssh_connect_options = connect_options(options).merge({:bootstrap => true, :echo => options[:echo]})
- ssh_connect(node, ssh_connect_options) do |ssh|
- if node.vagrant?
- ssh.install_insecure_vagrant_key
- end
- ssh.install_authorized_keys
- ssh.install_prerequisites
- ssh.leap.capture(facter_cmd) do |response|
- if response[:exitcode] == 0
- update_node_facts(node.name, response[:data])
- else
- log :failed, "to run facter on #{node.name}"
- end
- end
- end
- finished << node.name
- end
- log :completed, "initialization of nodes #{finished.join(', ')}"
- end
- end
-
node.desc 'Renames a node file, and all its related files.'
node.arg_name 'OLD_NAME NEW_NAME'
node.command :mv do |mv|
@@ -115,30 +81,6 @@ module LeapCli; module Commands
## PUBLIC HELPERS
##
- #
- # generates the known_hosts file.
- #
- # we do a 'late' binding on the hostnames and ip part of the ssh pub key record in order to allow
- # for the possibility that the hostnames or ip has changed in the node configuration.
- #
- def update_known_hosts
- buffer = StringIO.new
- buffer << "#\n"
- buffer << "# This file is automatically generated by the command `leap`. You should NOT modify this file.\n"
- buffer << "# Instead, rerun `leap node init` on whatever node is causing SSH problems.\n"
- buffer << "#\n"
- manager.nodes.keys.sort.each do |node_name|
- node = manager.nodes[node_name]
- hostnames = [node.name, node.domain.internal, node.domain.full, node.ip_address].join(',')
- pub_key = read_file([:node_ssh_pub_key,node.name])
- if pub_key
- buffer << [hostnames, pub_key].join(' ')
- buffer << "\n"
- end
- end
- write_file!(:known_hosts, buffer.string)
- end
-
def get_node_from_args(args, options={})
node_name = args.first
node = manager.node(node_name)
@@ -149,93 +91,6 @@ module LeapCli; module Commands
node
end
- private
-
- ##
- ## PRIVATE HELPERS
- ##
-
- #
- # saves the public ssh host key for node into the provider directory.
- #
- # see `man sshd` for the format of known_hosts
- #
- def save_public_host_key(node, global, options)
- log :fetching, "public SSH host key for #{node.name}"
- address = options[:ip] || node.ip_address
- port = options[:port] || node.ssh.port
- public_key = get_public_key_for_ip(address, port)
- pub_key_path = Path.named_path([:node_ssh_pub_key, node.name])
- if Path.exists?(pub_key_path)
- if public_key == SshKey.load(pub_key_path)
- log :trusted, "- Public SSH host key for #{node.name} matches previously saved key", :indent => 1
- else
- bail! do
- log :error, "The public SSH host key we just fetched for #{node.name} doesn't match what we have saved previously.", :indent => 1
- log "Remove the file #{pub_key_path} if you really want to change it.", :indent => 2
- end
- end
- elsif public_key.in_known_hosts?(node.name, node.ip_address, node.domain.name)
- log :trusted, "- Public SSH host key for #{node.name} is trusted (key found in your ~/.ssh/known_hosts)"
- else
- puts
- say("This is the SSH host key you got back from node \"#{node.name}\"")
- say("Type -- #{public_key.bits} bit #{public_key.type.upcase}")
- say("Fingerprint -- " + public_key.fingerprint)
- say("Public Key -- " + public_key.key)
- if !global[:yes] && !agree("Is this correct? ")
- bail!
- else
- puts
- write_file! [:node_ssh_pub_key, node.name], public_key.to_s
- end
- end
- end
-
- #
- # get the public host key for a host.
- # return SshKey object representation of the key.
- #
- # Only supports ecdsa or rsa host keys. rsa is preferred if both are available.
- #
- def get_public_key_for_ip(address, port=22)
- assert_bin!('ssh-keyscan')
- output = assert_run! "ssh-keyscan -p #{port} #{address}", "Could not get the public host key from #{address}:#{port}. Maybe sshd is not running?"
- if output.empty?
- bail! :failed, "ssh-keyscan returned empty output."
- end
-
- # key arrays [ip, key_type, public_key]
- rsa_key = nil
- ecdsa_key = nil
-
- lines = output.split("\n").grep(/^[^#]/)
- lines.each do |line|
- if line =~ /No route to host/
- bail! :failed, 'ssh-keyscan: no route to %s' % address
- elsif line =~ / ssh-rsa /
- rsa_key = line.split(' ')
- elsif line =~ / ecdsa-sha2-nistp256 /
- ecdsa_key = line.split(' ')
- end
- end
-
- if rsa_key.nil? && ecdsa_key.nil?
- bail! "ssh-keyscan got zero host keys back! Output was: #{output}"
- else
- key = rsa_key || ecdsa_key
- return SshKey.load(key[2], key[1])
- end
- end
-
- def is_node_alive(node, options)
- address = options[:ip] || node.ip_address
- port = options[:port] || node.ssh.port
- log :connecting, "to node #{node.name}"
- assert_run! "nc -zw3 #{address} #{port}",
- "Failed to reach #{node.name} (address #{address}, port #{port}). You can override the configured IP address and port with --ip or --port."
- end
-
def seed_node_data(node, args)
args.each do |seed|
key, value = seed.split(':')
diff --git a/lib/leap_cli/commands/node_init.rb b/lib/leap_cli/commands/node_init.rb
new file mode 100644
index 0000000..49030a7
--- /dev/null
+++ b/lib/leap_cli/commands/node_init.rb
@@ -0,0 +1,167 @@
+#
+# Node initialization.
+# Most of the fun stuff is in tasks.rb.
+#
+
+module LeapCli; module Commands
+
+ desc 'Node management'
+ command :node do |node|
+ node.desc 'Bootstraps a node or nodes, setting up SSH keys and installing prerequisite packages'
+ node.long_desc "This command prepares a server to be used with the LEAP Platform by saving the server's SSH host key, " +
+ "copying the authorized_keys file, installing packages that are required for deploying, and registering important facts. " +
+ "Node init must be run before deploying to a server, and the server must be running and available via the network. " +
+ "This command only needs to be run once, but there is no harm in running it multiple times."
+ node.arg_name 'FILTER'
+ node.command :init do |init|
+ init.switch 'echo', :desc => 'If set, passwords are visible as you type them (default is hidden)', :negatable => false
+ init.flag :port, :desc => 'Override the default SSH port.', :arg_name => 'PORT'
+ init.flag :ip, :desc => 'Override the default SSH IP address.', :arg_name => 'IPADDRESS'
+
+ init.action do |global,options,args|
+ assert! args.any?, 'You must specify a FILTER'
+ finished = []
+ manager.filter!(args).each_node do |node|
+ is_node_alive(node, options)
+ save_public_host_key(node, global, options) unless node.vagrant?
+ update_compiled_ssh_configs
+ ssh_connect_options = connect_options(options).merge({:bootstrap => true, :echo => options[:echo]})
+ ssh_connect(node, ssh_connect_options) do |ssh|
+ if node.vagrant?
+ ssh.install_insecure_vagrant_key
+ end
+ ssh.install_authorized_keys
+ ssh.install_prerequisites
+ ssh.leap.log(:checking, "SSH host keys") do
+ ssh.leap.capture(get_ssh_keys_cmd) do |response|
+ update_local_ssh_host_keys(node, response[:data]) if response[:exitcode] == 0
+ end
+ end
+ ssh.leap.log(:updating, "facts") do
+ ssh.leap.capture(facter_cmd) do |response|
+ if response[:exitcode] == 0
+ update_node_facts(node.name, response[:data])
+ else
+ log :failed, "to run facter on #{node.name}"
+ end
+ end
+ end
+ end
+ finished << node.name
+ end
+ log :completed, "initialization of nodes #{finished.join(', ')}"
+ end
+ end
+ end
+
+ private
+
+ ##
+ ## PRIVATE HELPERS
+ ##
+
+ def is_node_alive(node, options)
+ address = options[:ip] || node.ip_address
+ port = options[:port] || node.ssh.port
+ log :connecting, "to node #{node.name}"
+ assert_run! "nc -zw3 #{address} #{port}",
+ "Failed to reach #{node.name} (address #{address}, port #{port}). You can override the configured IP address and port with --ip or --port."
+ end
+
+ #
+ # saves the public ssh host key for node into the provider directory.
+ #
+ # see `man sshd` for the format of known_hosts
+ #
+ def save_public_host_key(node, global, options)
+ log :fetching, "public SSH host key for #{node.name}"
+ address = options[:ip] || node.ip_address
+ port = options[:port] || node.ssh.port
+ host_keys = get_public_keys_for_ip(address, port)
+ pub_key_path = Path.named_path([:node_ssh_pub_key, node.name])
+
+ if Path.exists?(pub_key_path)
+ if host_keys.include? SshKey.load(pub_key_path)
+ log :trusted, "- Public SSH host key for #{node.name} matches previously saved key", :indent => 1
+ else
+ bail! do
+ log :error, "The public SSH host keys we just fetched for #{node.name} doesn't match what we have saved previously.", :indent => 1
+ log "Delete the file #{pub_key_path} if you really want to remove the trusted SSH host key.", :indent => 2
+ end
+ end
+ else
+ known_key = host_keys.detect{|k|k.in_known_hosts?(node.name, node.ip_address, node.domain.name)}
+ if known_key
+ log :trusted, "- Public SSH host key for #{node.name} is trusted (key found in your ~/.ssh/known_hosts)"
+ else
+ public_key = SshKey.pick_best_key(host_keys)
+ if public_key.nil?
+ bail!("We got back #{host_keys.size} host keys from #{node.name}, but we can't support any of them.")
+ else
+ say(" This is the SSH host key you got back from node \"#{node.name}\"")
+ say(" Type -- #{public_key.bits} bit #{public_key.type.upcase}")
+ say(" Fingerprint -- " + public_key.fingerprint)
+ say(" Public Key -- " + public_key.key)
+ if !global[:yes] && !agree(" Is this correct? ")
+ bail!
+ else
+ known_key = public_key
+ end
+ end
+ end
+ puts
+ write_file! [:node_ssh_pub_key, node.name], known_key.to_s
+ end
+ end
+
+ #
+ # Get the public host keys for a host using ssh-keyscan.
+ # Return an array of SshKey objects, one for each key.
+ #
+ def get_public_keys_for_ip(address, port=22)
+ assert_bin!('ssh-keyscan')
+ output = assert_run! "ssh-keyscan -p #{port} #{address}", "Could not get the public host key from #{address}:#{port}. Maybe sshd is not running?"
+ if output.empty?
+ bail! :failed, "ssh-keyscan returned empty output."
+ end
+
+ if output =~ /No route to host/
+ bail! :failed, 'ssh-keyscan: no route to %s' % address
+ else
+ keys = SshKey.parse_keys(output)
+ if keys.empty?
+ bail! "ssh-keyscan got zero host keys back (that we understand)! Output was: #{output}"
+ else
+ return keys
+ end
+ end
+ end
+
+ # run on the server to generate a string suitable for passing to SshKey.parse_keys()
+ def get_ssh_keys_cmd
+ "/bin/grep ^HostKey /etc/ssh/sshd_config | /usr/bin/awk '{print $2 \".pub\"}' | /usr/bin/xargs /bin/cat"
+ end
+
+ #
+ # Sometimes the ssh host keys on the server will be better than what we have
+ # stored locally. In these cases, ask the user if they want to upgrade.
+ #
+ def update_local_ssh_host_keys(node, remote_keys_string)
+ remote_keys = SshKey.parse_keys(remote_keys_string)
+ return unless remote_keys.any?
+ current_key = SshKey.load(Path.named_path([:node_ssh_pub_key, node.name]))
+ best_key = SshKey.pick_best_key(remote_keys)
+ return unless best_key
+ if current_key != best_key
+ say(" One of the SSH host keys for node '#{node.name}' is better than what you currently have trusted.")
+ say(" Current key: #{current_key.summary}")
+ say(" Better key: #{best_key.summary}")
+ if agree(" Do you want to use the better key? ")
+ write_file! [:node_ssh_pub_key, node.name], best_key.to_s
+ end
+ else
+ log(3, "current host key does not need updating")
+ end
+ end
+
+end; end
diff --git a/lib/leap_cli/ssh_key.rb b/lib/leap_cli/ssh_key.rb
index bd5bf43..3cbeddd 100644
--- a/lib/leap_cli/ssh_key.rb
+++ b/lib/leap_cli/ssh_key.rb
@@ -1,6 +1,7 @@
#
# A wrapper around OpenSSL::PKey::RSA instances to provide a better api for dealing with SSH keys.
#
+# cipher 'ssh-ed25519' not supported yet because we are waiting for support in Net::SSH
#
require 'net/ssh'
@@ -13,6 +14,10 @@ module LeapCli
attr_accessor :filename
attr_accessor :comment
+ # supported ssh key types, in order of preference
+ SUPPORTED_TYPES = ['ssh-rsa', 'ecdsa-sha2-nistp256']
+ SUPPORTED_TYPES_RE = /(#{SUPPORTED_TYPES.join('|')})/
+
##
## CLASS METHODS
##
@@ -64,6 +69,44 @@ module LeapCli
public_key || private_key
end
+ #
+ # Picks one key out of an array of keys that we think is the "best",
+ # based on the order of preference in SUPPORTED_TYPES
+ #
+ # Currently, this does not take bitsize into account.
+ #
+ def self.pick_best_key(keys)
+ keys.select {|k|
+ SUPPORTED_TYPES.include?(k.type)
+ }.sort {|a,b|
+ SUPPORTED_TYPES.index(a.type) <=> SUPPORTED_TYPES.index(b.type)
+ }.first
+ end
+
+ #
+ # takes a string with one or more ssh keys, one key per line,
+ # and returns an array of SshKey objects.
+ #
+ # the lines should be in one of these formats:
+ #
+ # 1. <hostname> <key-type> <key>
+ # 2. <key-type> <key>
+ #
+ def self.parse_keys(string)
+ keys = []
+ lines = string.split("\n").grep(/^[^#]/)
+ lines.each do |line|
+ if line =~ / #{SshKey::SUPPORTED_TYPES_RE} /
+ # <hostname> <key-type> <key>
+ keys << line.split(' ')[1..2]
+ elsif line =~ /^#{SshKey::SUPPORTED_TYPES_RE} /
+ # <key-type> <key>
+ keys << line.split(' ')
+ end
+ end
+ return keys.map{|k| SshKey.load(k[1], k[0])}
+ end
+
##
## INSTANCE METHODS
##
@@ -101,7 +144,8 @@ module LeapCli
end
def summary
- "%s %s %s (%s)" % [self.type, self.bits, self.fingerprint, self.filename || self.comment || '']
+ #"%s %s %s (%s)" % [self.type, self.bits, self.fingerprint, self.filename || self.comment || '']
+ "%s %s %s" % [self.type, self.bits, self.fingerprint]
end
def to_s