diff options
| -rw-r--r-- | lib/leap_cli/commands/compile.rb | 24 | ||||
| -rw-r--r-- | lib/leap_cli/commands/node.rb | 155 | ||||
| -rw-r--r-- | lib/leap_cli/commands/node_init.rb | 167 | ||||
| -rw-r--r-- | lib/leap_cli/ssh_key.rb | 46 | 
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  | 
