]> gitweb.fluxo.info Git - leap/leap_cli.git/commitdiff
prompt user to update ssh host keys when a better one is available. closes #6320
authorelijah <elijah@riseup.net>
Wed, 5 Nov 2014 23:44:24 +0000 (15:44 -0800)
committerelijah <elijah@riseup.net>
Wed, 5 Nov 2014 23:44:24 +0000 (15:44 -0800)
lib/leap_cli/commands/compile.rb
lib/leap_cli/commands/node.rb
lib/leap_cli/commands/node_init.rb [new file with mode: 0644]
lib/leap_cli/ssh_key.rb

index 644ce2a7bfddc2041763cd3300b59cc4d534579e..b30aaea73f32092503aaa9d47683b80b0d59fd0a 100644 (file)
@@ -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
     ##
index 190d348297491c3bb3628c585598c2f3d8e01f53..6709077131e9e04570ac845d0179a0403b0db1b8 100644 (file)
@@ -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 (file)
index 0000000..49030a7
--- /dev/null
@@ -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
index bd5bf43c3d0f754ac55093bd81bb6a4c63c000cf..3cbeddd2ad713c2fcbc0232905983ced648c8733 100644 (file)
@@ -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