From 2c697c574a6844c6cec3dc0cb6498cc0f87ff072 Mon Sep 17 00:00:00 2001 From: elijah Date: Wed, 5 Nov 2014 15:44:24 -0800 Subject: prompt user to update ssh host keys when a better one is available. closes #6320 --- lib/leap_cli/commands/compile.rb | 24 ++++++ lib/leap_cli/commands/node.rb | 155 ++-------------------------------- lib/leap_cli/commands/node_init.rb | 167 +++++++++++++++++++++++++++++++++++++ lib/leap_cli/ssh_key.rb | 46 +++++++++- 4 files changed, 241 insertions(+), 151 deletions(-) create mode 100644 lib/leap_cli/commands/node_init.rb 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. + # 2. + # + def self.parse_keys(string) + keys = [] + lines = string.split("\n").grep(/^[^#]/) + lines.each do |line| + if line =~ / #{SshKey::SUPPORTED_TYPES_RE} / + # + keys << line.split(' ')[1..2] + elsif line =~ /^#{SshKey::SUPPORTED_TYPES_RE} / + # + 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 -- cgit v1.2.3