diff options
Diffstat (limited to 'lib/leap_cli/commands')
| -rw-r--r-- | lib/leap_cli/commands/ca.rb | 649 | ||||
| -rw-r--r-- | lib/leap_cli/commands/compile.rb | 1 | ||||
| -rw-r--r-- | lib/leap_cli/commands/db.rb | 2 | ||||
| -rw-r--r-- | lib/leap_cli/commands/deploy.rb | 207 | ||||
| -rw-r--r-- | lib/leap_cli/commands/facts.rb | 13 | ||||
| -rw-r--r-- | lib/leap_cli/commands/info.rb | 15 | ||||
| -rw-r--r-- | lib/leap_cli/commands/inspect.rb | 46 | ||||
| -rw-r--r-- | lib/leap_cli/commands/list.rb | 93 | ||||
| -rw-r--r-- | lib/leap_cli/commands/node.rb | 177 | ||||
| -rw-r--r-- | lib/leap_cli/commands/node_init.rb | 104 | ||||
| -rw-r--r-- | lib/leap_cli/commands/open.rb | 103 | ||||
| -rw-r--r-- | lib/leap_cli/commands/run.rb | 50 | ||||
| -rw-r--r-- | lib/leap_cli/commands/ssh.rb | 17 | ||||
| -rw-r--r-- | lib/leap_cli/commands/test.rb | 41 | ||||
| -rw-r--r-- | lib/leap_cli/commands/user.rb | 130 | ||||
| -rw-r--r-- | lib/leap_cli/commands/util.rb | 50 | ||||
| -rw-r--r-- | lib/leap_cli/commands/vagrant.rb | 25 | ||||
| -rw-r--r-- | lib/leap_cli/commands/vm.rb | 467 | 
18 files changed, 1304 insertions, 886 deletions
| diff --git a/lib/leap_cli/commands/ca.rb b/lib/leap_cli/commands/ca.rb index 1b311eee..3c5fc7d5 100644 --- a/lib/leap_cli/commands/ca.rb +++ b/lib/leap_cli/commands/ca.rb @@ -1,8 +1,3 @@ -autoload :OpenSSL, 'openssl' -autoload :CertificateAuthority, 'certificate_authority' -autoload :Date, 'date' -require 'digest/md5' -  module LeapCli; module Commands    desc "Manage X.509 certificates" @@ -35,41 +30,15 @@ module LeapCli; module Commands      cert.desc 'Creates a Diffie-Hellman parameter file, needed for forward secret OpenVPN ciphers.' # (needed for server-side of some TLS connections)      cert.command :dh do |dh|        dh.action do |global_options,options,args| -        long_running do -          if cmd_exists?('certtool') -            log 0, 'Generating DH parameters (takes a long time)...' -            output = assert_run!('certtool --generate-dh-params --sec-param high') -            output.sub! /.*(-----BEGIN DH PARAMETERS-----.*-----END DH PARAMETERS-----).*/m, '\1' -            output << "\n" -            write_file!(:dh_params, output) -          else -            log 0, 'Generating DH parameters (takes a REALLY long time)...' -            output = OpenSSL::PKey::DH.generate(3248).to_pem -            write_file!(:dh_params, output) -          end -        end +        generate_dh        end      end -    # -    # hints: -    # -    # inspect CSR: -    #   openssl req -noout -text -in files/cert/x.csr -    # -    # generate CSR with openssl to see how it compares: -    #   openssl req -sha256 -nodes -newkey rsa:2048 -keyout example.key -out example.csr -    # -    # validate a CSR: -    #   http://certlogik.com/decoder/ -    # -    # nice details about CSRs: -    #   http://www.redkestrel.co.uk/Articles/CSR.html -    #      cert.desc "Creates a CSR for use in buying a commercial X.509 certificate."      cert.long_desc "Unless specified, the CSR is created for the provider's primary domain. "+        "The properties used for this CSR come from `provider.ca.server_certificates`, "+        "but may be overridden here." +    cert.arg_name "DOMAIN"      cert.command :csr do |csr|        csr.flag 'domain', :arg_name => 'DOMAIN', :desc => 'Specify what domain to create the CSR for.'        csr.flag ['organization', 'O'], :arg_name => 'ORGANIZATION', :desc => "Override default O in distinguished name." @@ -81,70 +50,26 @@ module LeapCli; module Commands        csr.flag :bits, :arg_name => 'BITS', :desc => "Override default certificate bit length"        csr.flag :digest, :arg_name => 'DIGEST', :desc => "Override default signature digest"        csr.action do |global_options,options,args| -        assert_config! 'provider.domain' -        assert_config! 'provider.name' -        assert_config! 'provider.default_language' -        assert_config! 'provider.ca.server_certificates.bit_size' -        assert_config! 'provider.ca.server_certificates.digest' -        domain = options[:domain] || provider.domain - -        unless global_options[:force] -          assert_files_missing! [:commercial_key, domain], [:commercial_csr, domain], -            :msg => 'If you really want to create a new key and CSR, remove these files first or run with --force.' -        end - -        server_certificates = provider.ca.server_certificates - -        # RSA key -        keypair = CertificateAuthority::MemoryKeyMaterial.new -        bit_size = (options[:bits] || server_certificates.bit_size).to_i -        log :generating, "%s bit RSA key" % bit_size do -          keypair.generate_key(bit_size) -          write_file! [:commercial_key, domain], keypair.private_key.to_pem -        end - -        # CSR -        dn  = CertificateAuthority::DistinguishedName.new -        dn.common_name   = domain -        dn.organization  = options[:organization] || provider.name[provider.default_language] -        dn.ou            = options[:organizational_unit] # optional -        dn.email_address = options[:email] # optional -        dn.country       = options[:country] || server_certificates['country']   # optional -        dn.state         = options[:state] || server_certificates['state']       # optional -        dn.locality      = options[:locality] || server_certificates['locality'] # optional - -        digest = options[:digest] || server_certificates.digest -        log :generating, "CSR with #{digest} digest and #{print_dn(dn)}" do -          csr = create_csr(dn, keypair, digest) -          request = csr.to_x509_csr -          write_file! [:commercial_csr, domain], csr.to_pem -        end +        generate_csr(global_options, options, args) +      end +    end -        # Sign using our own CA, for use in testing but hopefully not production. -        # It is not that commerical CAs are so secure, it is just that signing your own certs is -        # a total drag for the user because they must click through dire warnings. -        #if options[:sign] -          log :generating, "self-signed x509 server certificate for testing purposes" do -            cert = csr.to_cert -            cert.serial_number.number = cert_serial_number(domain) -            cert.not_before = yesterday -            cert.not_after  = yesterday.advance(:years => 1) -            cert.parent = ca_root -            cert.sign! domain_test_signing_profile -            write_file! [:commercial_cert, domain], cert.to_pem -            log "please replace this file with the real certificate you get from a CA using #{Path.relative_path([:commercial_csr, domain])}" -          end -        #end +    cert.desc "Register an authorization key with the CA letsencrypt.org" +    cert.long_desc "This only needs to be done once." +    cert.command :register do |register| +      register.action do |global, options, args| +        do_register_key(global, options, args) +      end +    end -        # FAKE CA -        unless file_exists? :commercial_ca_cert -          log :using, "generated CA in place of commercial CA for testing purposes" do -            write_file! :commercial_ca_cert, read_file!(:ca_cert) -            log "please also replace this file with the CA cert from the commercial authority you use." -          end -        end +    cert.desc "Renews a certificate using the CA letsencrypt.org" +    cert.arg_name "DOMAIN" +    cert.command :renew do |renew| +      renew.action do |global, options, args| +        do_renew_cert(global, options, args)        end      end +    end    protected @@ -153,6 +78,7 @@ module LeapCli; module Commands    # will generate new certificates for the specified nodes, if needed.    #    def update_certificates(nodes, options={}) +    require 'leap_cli/x509'      assert_files_exist! :ca_cert, :ca_key, :msg => 'Run `leap cert ca` to create them'      assert_config! 'provider.ca.server_certificates.bit_size'      assert_config! 'provider.ca.server_certificates.digest' @@ -160,382 +86,281 @@ module LeapCli; module Commands      assert_config! 'common.x509.use'      nodes.each_node do |node| -      warn_if_commercial_cert_will_soon_expire(node) +      node.warn_if_commercial_cert_will_soon_expire        if !node.x509.use          remove_file!([:node_x509_key, node.name])          remove_file!([:node_x509_cert, node.name]) -      elsif options[:force] || cert_needs_updating?(node) -        generate_cert_for_node(node) +      elsif options[:force] || node.cert_needs_updating? +        node.generate_cert        end      end    end +  # +  # yields client key and cert suitable for testing +  # +  def generate_test_client_cert(prefix=nil) +    require 'leap_cli/x509' +    cert = CertificateAuthority::Certificate.new +    cert.serial_number.number = cert_serial_number(provider.domain) +    cert.subject.common_name = [prefix, random_common_name(provider.domain)].join +    cert.not_before = X509.yesterday +    cert.not_after  = X509.yesterday.advance(:years => 1) +    cert.key_material.generate_key(1024) # just for testing, remember! +    cert.parent = client_ca_root +    cert.sign! client_test_signing_profile +    yield cert.key_material.private_key.to_pem, cert.to_pem +  end +    private    def generate_new_certificate_authority(key_file, cert_file, common_name) +    require 'leap_cli/x509'      assert_files_missing! key_file, cert_file      assert_config! 'provider.ca.name'      assert_config! 'provider.ca.bit_size'      assert_config! 'provider.ca.life_span' -    root = CertificateAuthority::Certificate.new +    root = X509.new_ca(provider.ca, common_name) -    # set subject -    root.subject.common_name = common_name -    possible = ['country', 'state', 'locality', 'organization', 'organizational_unit', 'email_address'] -    provider.ca.keys.each do |key| -      if possible.include?(key) -        root.subject.send(key + '=', provider.ca[key]) +    write_file!(key_file, root.key_material.private_key.to_pem) +    write_file!(cert_file, root.to_pem) +  end + +  def generate_dh +    require 'leap_cli/x509' +    long_running do +      if cmd_exists?('certtool') +        log 0, 'Generating DH parameters (takes a long time)...' +        output = assert_run!('certtool --generate-dh-params --sec-param high') +        output.sub!(/.*(-----BEGIN DH PARAMETERS-----.*-----END DH PARAMETERS-----).*/m, '\1') +        output << "\n" +        write_file!(:dh_params, output) +      else +        log 0, 'Generating DH parameters (takes a REALLY long time)...' +        output = OpenSSL::PKey::DH.generate(3248).to_pem +        write_file!(:dh_params, output)        end      end +  end -    # set expiration -    root.not_before = yesterday -    root.not_after = yesterday_advance(provider.ca.life_span) - -    # generate private key -    root.serial_number.number = 1 -    root.key_material.generate_key(provider.ca.bit_size) +  # +  # hints: +  # +  # inspect CSR: +  #   openssl req -noout -text -in files/cert/x.csr +  # +  # generate CSR with openssl to see how it compares: +  #   openssl req -sha256 -nodes -newkey rsa:2048 -keyout example.key -out example.csr +  # +  # validate a CSR: +  #   http://certlogik.com/decoder/ +  # +  # nice details about CSRs: +  #   http://www.redkestrel.co.uk/Articles/CSR.html +  # +  def generate_csr(global_options, options, args) +    require 'leap_cli/x509' +    assert_config! 'provider.domain' +    assert_config! 'provider.name' +    assert_config! 'provider.default_language' +    assert_config! 'provider.ca.server_certificates.bit_size' +    assert_config! 'provider.ca.server_certificates.digest' -    # sign self -    root.signing_entity = true -    root.parent = root -    root.sign!(ca_root_signing_profile) +    server_certificates      = provider.ca.server_certificates +    options[:domain]       ||= args.first || provider.domain +    options[:organization] ||= provider.name[provider.default_language] +    options[:country]      ||= server_certificates['country'] +    options[:state]        ||= server_certificates['state'] +    options[:locality]     ||= server_certificates['locality'] +    options[:bits]         ||= server_certificates.bit_size +    options[:digest]       ||= server_certificates.digest + +    unless global_options[:force] +      assert_files_missing! [:commercial_key, options[:domain]], [:commercial_csr, options[:domain]], +        :msg => 'If you really want to create a new key and CSR, remove these files first or run with --force.' +    end -    # save -    write_file!(key_file, root.key_material.private_key.to_pem) -    write_file!(cert_file, root.to_pem) +    X509.create_csr_and_cert(options)    end    # -  # returns true if the certs associated with +node+ need to be regenerated. +  # letsencrypt.org    # -  def cert_needs_updating?(node) -    if !file_exists?([:node_x509_cert, node.name], [:node_x509_key, node.name]) -      return true -    else -      cert = load_certificate_file([:node_x509_cert, node.name]) -      if !created_by_authority?(cert, ca_root) -        log :updating, "cert for node '#{node.name}' because it was signed by an old CA root cert." -        return true -      end -      if cert.not_after < Time.now.advance(:months => 2) -        log :updating, "cert for node '#{node.name}' because it will expire soon" -        return true -      end -      if cert.subject.common_name != node.domain.full -        log :updating, "cert for node '#{node.name}' because domain.full has changed (was #{cert.subject.common_name}, now #{node.domain.full})" -        return true + +  def do_register_key(global, options, args) +    require 'leap_cli/acme' +    assert_config! 'provider.contacts.default' +    contact = manager.provider.contacts.default.first + +    if file_exists?(:acme_key) && !global[:force] +      bail! do +        log "the authorization key for letsencrypt.org already exists" +        log "run with --force if you really want to register a new key."        end -      cert.openssl_body.extensions.each do |ext| -        if ext.oid == "subjectAltName" -          ips = [] -          dns_names = [] -          ext.value.split(",").each do |value| -            value.strip! -            ips << $1          if value =~ /^IP Address:(.*)$/ -            dns_names << $1    if value =~ /^DNS:(.*)$/ -          end -          dns_names.sort! -          if ips.first != node.ip_address -            log :updating, "cert for node '#{node.name}' because ip_address has changed (from #{ips.first} to #{node.ip_address})" -            return true -          elsif dns_names != dns_names_for_node(node) -            log :updating, "cert for node '#{node.name}' because domain name aliases have changed\n    from: #{dns_names.inspect}\n    to: #{dns_names_for_node(node).inspect})" -            return true -          end +    else +      private_key = Acme.new_private_key +      registration = nil + +      log(:registering, "letsencrypt.org authorization key using contact `%s`" % contact) do +        acme = Acme.new(key: private_key) +        registration = acme.register(contact) +        if registration +          log 'success!', :color => :green, :style => :bold +        else +          bail! "could not register authorization key."          end        end -    end -    return false -  end -  def created_by_authority?(cert, ca) -    authority_key_id = cert.extensions["authorityKeyIdentifier"].identifier.sub(/^keyid:/, '') -    authority_key_id == public_key_id_for_ca(ca) -  end - -  # calculate the "key id" for a root CA, that matches the value -  # Authority Key Identifier in the x509 extensions of a cert. -  def public_key_id_for_ca(ca_cert) -    @ca_key_ids ||= {} -    @ca_key_ids[ca_cert.object_id] ||= begin -      pubkey = ca_cert.key_material.public_key -      seq = OpenSSL::ASN1::Sequence([ -        OpenSSL::ASN1::Integer.new(pubkey.n), -        OpenSSL::ASN1::Integer.new(pubkey.e) -      ]) -      Digest::SHA1.hexdigest(seq.to_der).upcase.scan(/../).join(':') +      log :saving, "authorization key for letsencrypt.org" do +        write_file!(:acme_key, private_key.to_pem) +        write_file!(:acme_info, JSON.sorted_generate({ +          id: registration.id, +          contact: registration.contact, +          key: registration.key, +          uri: registration.uri +        })) +        log :warning, "keep key file private!" +      end      end    end -  def warn_if_commercial_cert_will_soon_expire(node) -    dns_names_for_node(node).each do |domain| -      if file_exists?([:commercial_cert, domain]) -        cert = load_certificate_file([:commercial_cert, domain]) -        path = Path.relative_path([:commercial_cert, domain]) -        if cert.not_after < Time.now.utc -          log :error, "the commercial certificate '#{path}' has EXPIRED! " + -            "You should renew it with `leap cert csr --domain #{domain}`." -        elsif cert.not_after < Time.now.advance(:months => 2) -          log :warning, "the commercial certificate '#{path}' will expire soon (#{cert.not_after}). "+ -            "You should renew it with `leap cert csr --domain #{domain}`." -        end -      end +  def assert_no_errors!(msg) +    yield +  rescue StandardError => exc +    bail! :error, msg do +      log exc.to_s      end    end -  def generate_cert_for_node(node) -    return if node.x509.use == false - -    cert = CertificateAuthority::Certificate.new - -    # set subject -    cert.subject.common_name = node.domain.full -    cert.serial_number.number = cert_serial_number(node.domain.full) +  def do_renew_cert(global, options, args) +    require 'leap_cli/acme' +    require 'leap_cli/ssh' +    require 'socket' +    require 'net/http' -    # set expiration -    cert.not_before = yesterday -    cert.not_after = yesterday_advance(provider.ca.server_certificates.life_span) +    csr = nil +    account_key = nil +    cert = nil +    acme = nil -    # generate key -    cert.key_material.generate_key(provider.ca.server_certificates.bit_size) - -    # sign -    cert.parent = ca_root -    cert.sign!(server_signing_profile(node)) - -    # save -    write_file!([:node_x509_key, node.name], cert.key_material.private_key.to_pem) -    write_file!([:node_x509_cert, node.name], cert.to_pem) -  end - -  # -  # yields client key and cert suitable for testing -  # -  def generate_test_client_cert(prefix=nil) -    cert = CertificateAuthority::Certificate.new -    cert.serial_number.number = cert_serial_number(provider.domain) -    cert.subject.common_name = [prefix, random_common_name(provider.domain)].join -    cert.not_before = yesterday -    cert.not_after  = yesterday.advance(:years => 1) -    cert.key_material.generate_key(1024) # just for testing, remember! -    cert.parent = client_ca_root -    cert.sign! client_test_signing_profile -    yield cert.key_material.private_key.to_pem, cert.to_pem -  end - -  # -  # creates a CSR and returns it. -  # with the correct extReq attribute so that the CA -  # doens't generate certs with extensions we don't want. -  # -  def create_csr(dn, keypair, digest) -    csr = CertificateAuthority::SigningRequest.new -    csr.distinguished_name = dn -    csr.key_material = keypair -    csr.digest = digest - -    # define extensions manually (library doesn't support setting these on CSRs) -    extensions = [] -    extensions << CertificateAuthority::Extensions::BasicConstraints.new.tap {|basic| -      basic.ca = false -    } -    extensions << CertificateAuthority::Extensions::KeyUsage.new.tap {|keyusage| -      keyusage.usage = ["digitalSignature", "keyEncipherment"] -    } -    extensions << CertificateAuthority::Extensions::ExtendedKeyUsage.new.tap {|extkeyusage| -      extkeyusage.usage = [ "serverAuth"] -    } - -    # convert extensions to attribute 'extReq' -    # aka "Requested Extensions" -    factory = OpenSSL::X509::ExtensionFactory.new -    attrval = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence( -      extensions.map{|e| factory.create_ext(e.openssl_identifier, e.to_s, e.critical)} -    )]) -    attrs = [ -      OpenSSL::X509::Attribute.new("extReq", attrval), -    ] -    csr.attributes = attrs - -    return csr -  end +    # +    # sanity check the domain +    # +    domain = args.first +    nodes  = nodes_for_domain(domain) +    domain_ready_for_acme!(domain) -  def ca_root -    @ca_root ||= begin -      load_certificate_file(:ca_cert, :ca_key) +    # +    # load key material +    # +    assert_files_exist!([:commercial_key, domain], [:commercial_csr, domain], +      :msg => 'Please create the CSR first with `leap cert csr %s`' % domain) +    assert_no_errors!("Could not load #{path([:commercial_csr, domain])}") do +      csr = Acme.load_csr(read_file!([:commercial_csr, domain]))      end -  end - -  def client_ca_root -    @client_ca_root ||= begin -      load_certificate_file(:client_ca_cert, :client_ca_key) +    assert_files_exist!(:acme_key, +      :msg => "Please run `leap cert register` first. This only needs to be done once.") +    assert_no_errors!("Could not load #{path(:acme_key)}") do +      account_key = Acme.load_private_key(read_file!(:acme_key))      end -  end -  def load_certificate_file(crt_file, key_file=nil, password=nil) -    crt = read_file!(crt_file) -    openssl_cert = OpenSSL::X509::Certificate.new(crt) -    cert = CertificateAuthority::Certificate.from_openssl(openssl_cert) -    if key_file -      key = read_file!(key_file) -      cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, password) +    # +    # check authorization for this domain +    # +    log :checking, "authorization" +    acme = Acme.new(domain: domain, key: account_key) +    status, message = acme.authorize do |challenge| +      log(:uploading, 'challenge to server %s' % domain) do +        SSH.remote_command(nodes) do |ssh, host| +          ssh.scripts.upload_acme_challenge(challenge.token, challenge.file_content) +        end +      end +      log :waiting, "for letsencrypt.org to verify challenge" +    end +    if status == 'valid' +      log 'authorized!', color: :green, style: :bold +    elsif status == 'error' +      bail! :error, message +    elsif status == 'unauthorized' +      bail!(:unauthorized, message, color: :yellow, style: :bold) do +        log 'You must first run `leap cert register` to register the account key with letsencrypt.org' +      end      end -    return cert -  end - -  def ca_root_signing_profile -    { -      "extensions" => { -        "basicConstraints" => {"ca" => true}, -        "keyUsage" => { -          "usage" => ["critical", "keyCertSign"] -        }, -        "extendedKeyUsage" => { -          "usage" => [] -        } -      } -    } -  end -  # -  # For keyusage, openvpn server certs can have keyEncipherment or keyAgreement. -  # Web browsers seem to break without keyEncipherment. -  # For now, I am using digitalSignature + keyEncipherment -  # -  # * digitalSignature -- for (EC)DHE cipher suites -  #   "The digitalSignature bit is asserted when the subject public key is used -  #    with a digital signature mechanism to support security services other -  #    than certificate signing (bit 5), or CRL signing (bit 6). Digital -  #    signature mechanisms are often used for entity authentication and data -  #    origin authentication with integrity." -  # -  # * keyEncipherment  ==> for plain RSA cipher suites -  #   "The keyEncipherment bit is asserted when the subject public key is used for -  #    key transport. For example, when an RSA key is to be used for key management, -  #    then this bit is set." -  # -  # * keyAgreement     ==> for used with DH, not RSA. -  #   "The keyAgreement bit is asserted when the subject public key is used for key -  #    agreement. For example, when a Diffie-Hellman key is to be used for key -  #    management, then this bit is set." -  # -  # digest options: SHA512, SHA256, SHA1 -  # -  def server_signing_profile(node) -    { -      "digest" => provider.ca.server_certificates.digest, -      "extensions" => { -        "keyUsage" => { -          "usage" => ["digitalSignature", "keyEncipherment"] -        }, -        "extendedKeyUsage" => { -          "usage" => ["serverAuth", "clientAuth"] -        }, -        "subjectAltName" => { -          "ips" => [node.ip_address], -          "dns_names" => dns_names_for_node(node) -        } -      } -    } +    log :fetching, "new certificate from letsencrypt.org" +    assert_no_errors!("could not renew certificate") do +      cert = acme.get_certificate(csr) +    end +    log 'success', color: :green, style: :bold +    write_file!([:commercial_cert, domain], cert.fullchain_to_pem) +    log 'You should now run `leap deploy` to deploy the new certificate.'    end    # -  # This is used when signing the main cert for the provider's domain -  # with our own CA (for testing purposes). Typically, this cert would -  # be purchased from a commercial CA, and not signed this way. +  # Returns a hash of nodes that match this domain. It also checks:    # -  def domain_test_signing_profile -    { -      "digest" => "SHA256", -      "extensions" => { -        "keyUsage" => { -          "usage" => ["digitalSignature", "keyEncipherment"] -        }, -        "extendedKeyUsage" => { -          "usage" => ["serverAuth"] -        } -      } -    } -  end - +  # * a node configuration has this domain +  # * the dns for the domain exists    # -  # This is used when signing a dummy client certificate that is only to be -  # used for testing. +  # This method will bail if any checks fail.    # -  def client_test_signing_profile -    { -      "digest" => "SHA256", -      "extensions" => { -        "keyUsage" => { -          "usage" => ["digitalSignature"] -        }, -        "extendedKeyUsage" => { -          "usage" => ["clientAuth"] -        } -      } -    } -  end - -  def dns_names_for_node(node) -    names = [node.domain.internal, node.domain.full] -    if node['dns'] && node.dns['aliases'] && node.dns.aliases.any? -      names += node.dns.aliases +  def nodes_for_domain(domain) +    bail! { log 'Argument DOMAIN is required' } if domain.nil? || domain.empty? +    nodes = manager.nodes['dns.aliases' => domain] +    if nodes.empty? +      bail! :error, "There are no nodes configured for domain `%s`" % domain      end -    names.compact! -    names.sort! -    names.uniq! -    return names +    begin +      ips = Socket.getaddrinfo(domain, 'http').map {|record| record[2]}.uniq +      nodes = nodes['ip_address' => ips] +      if nodes.empty? +        bail! do +          log :error, "The domain `%s` resolves to [%s]" % [domain, ips.join(', ')] +          log :error, "But there no nodes configured for this domain with these adddresses." +        end +      end +    rescue SocketError +      bail! :error, "Could not resolve the DNS for `#{domain}`. Without a DNS " + +        "entry for this domain, authorization will not work." +    end +    return nodes    end    # -  # For cert serial numbers, we need a non-colliding number less than 160 bits. -  # md5 will do nicely, since there is no need for a secure hash, just a short one. -  # (md5 is 128 bits) +  # runs the following checks on the domain:    # -  def cert_serial_number(domain_name) -    Digest::MD5.hexdigest("#{domain_name} -- #{Time.now}").to_i(16) -  end - +  # * we are able to get /.well-known/acme-challenge/ok    # -  # for the random common name, we need a text string that will be unique across all certs. -  # ruby 1.8 doesn't have a built-in uuid generator, or we would use SecureRandom.uuid +  # This method will bail if any checks fail.    # -  def random_common_name(domain_name) -    cert_serial_number(domain_name).to_s(36) -  end - -  # prints CertificateAuthority::DistinguishedName fields -  def print_dn(dn) -    fields = {} -    [:common_name, :locality, :state, :country, :organization, :organizational_unit, :email_address].each do |attr| -      fields[attr] = dn.send(attr) if dn.send(attr) -    end -    fields.inspect -  end - -  ## -  ## TIME HELPERS -  ## -  ## note: we use 'yesterday' instead of 'today', because times are in UTC, and some people on the planet -  ## are behind UTC. -  ## - -  def yesterday -    t = Time.now - 24*24*60 -    Time.utc t.year, t.month, t.day -  end - -  def yesterday_advance(string) -    number, unit = string.split(' ') -    unless ['years', 'months', 'days', 'hours', 'minutes'].include? unit -      bail!("The time property '#{string}' is missing a unit (one of: years, months, days, hours, minutes).") -    end -    unless number.to_i.to_s == number -      bail!("The time property '#{string}' is missing a number.") +  def domain_ready_for_acme!(domain) +    begin +      uri = URI("https://#{domain}/.well-known/acme-challenge/ok") +      options = { +        use_ssl: true, +        open_timeout: 5, +        verify_mode: OpenSSL::SSL::VERIFY_NONE +      } +      Net::HTTP.start(uri.host, uri.port, options) do |http| +        http.request(Net::HTTP::Get.new(uri)) do |response| +          if !response.is_a?(Net::HTTPSuccess) +            bail!(:error, "Could not GET %s" % uri) do +              log "%s %s" % [response.code, response.message] +              log "You may need to run `leap deploy`" +            end +          end +        end +      end +    rescue Errno::ETIMEDOUT, Net::OpenTimeout +      bail! :error, "Connection attempt timed out: %s" % uri +    rescue Interrupt +      bail! +    rescue StandardError => exc +      bail!(:error, "Could not GET %s" % uri) do +        log exc.to_s +      end      end -    yesterday.advance(unit.to_sym => number.to_i)    end  end; end diff --git a/lib/leap_cli/commands/compile.rb b/lib/leap_cli/commands/compile.rb index f9079279..92c879d7 100644 --- a/lib/leap_cli/commands/compile.rb +++ b/lib/leap_cli/commands/compile.rb @@ -284,7 +284,6 @@ remove this directory if you don't use it.        # note: we use the default provider for all nodes, because we use it        # to generate hostnames that are relative to the default domain.        provider   = manager.env('default').provider -      hosts_seen = {}        lines      = []        # diff --git a/lib/leap_cli/commands/db.rb b/lib/leap_cli/commands/db.rb index 5307ac4d..227d429d 100644 --- a/lib/leap_cli/commands/db.rb +++ b/lib/leap_cli/commands/db.rb @@ -50,7 +50,7 @@ module LeapCli; module Commands    def destroy_all_dbs(nodes)      ssh_connect(nodes) do |ssh| -      ssh.run('/etc/init.d/bigcouch stop && test ! -z "$(ls /opt/bigcouch/var/lib/ 2> /dev/null)" && rm -r /opt/bigcouch/var/lib/* && echo "All DBs destroyed" || echo "DBs already destroyed"') +      ssh.run('/etc/init.d/couchdb stop && test ! -z "$(ls /var/lib/couchdb 2> /dev/null)" && rm -r /var/lib/couchdb/* && echo "All DBs destroyed" || echo "DBs already destroyed"')      end    end diff --git a/lib/leap_cli/commands/deploy.rb b/lib/leap_cli/commands/deploy.rb index 9dd190ab..91e25a96 100644 --- a/lib/leap_cli/commands/deploy.rb +++ b/lib/leap_cli/commands/deploy.rb @@ -29,57 +29,7 @@ module LeapCli                      :arg_name => 'IPADDRESS'        c.action do |global,options,args| - -        if options[:dev] != true -          init_submodules -        end - -        nodes = manager.filter!(args, :disabled => false) -        if nodes.size > 1 -          say "Deploying to these nodes: #{nodes.keys.join(', ')}" -          if !global[:yes] && !agree("Continue? ") -            quit! "OK. Bye." -          end -        end - -        environments = nodes.field('environment').uniq -        if environments.empty? -          environments = [nil] -        end -        environments.each do |env| -          check_platform_pinning(env, global) -        end - -        # compile hiera files for all the nodes in every environment that is -        # being deployed and only those environments. -        compile_hiera_files(manager.filter(environments), false) - -        ssh_connect(nodes, connect_options(options)) do |ssh| -          ssh.leap.log :checking, 'node' do -            ssh.leap.check_for_no_deploy -            ssh.leap.assert_initialized -          end -          ssh.leap.log :synching, "configuration files" do -            sync_hiera_config(ssh) -            sync_support_files(ssh) -          end -          ssh.leap.log :synching, "puppet manifests" do -            sync_puppet_files(ssh) -          end -          unless options[:sync] -            ssh.leap.log :applying, "puppet" do -              ssh.puppet.apply(:verbosity => [LeapCli.log_level,5].min, -                :tags => tags(options), -                :force => options[:force], -                :info => deploy_info, -                :downgrade => options[:downgrade] -              ) -            end -          end -        end -        if !Util.exit_status.nil? && Util.exit_status != 0 -          log :warning, "puppet did not finish successfully." -        end +        run_deploy(global, options, args)        end      end @@ -94,19 +44,91 @@ module LeapCli        c.switch :last, :desc => 'Show last deploy only',                        :negatable => false        c.action do |global,options,args| -        if options[:last] == true -          lines = 1 -        else -          lines = 10 +        run_history(global, options, args) +      end +    end + +    private + +    def run_deploy(global, options, args) +      require 'leap_cli/ssh' + +      if options[:dev] != true +        init_submodules +      end + +      nodes = manager.filter!(args, :disabled => false) +      if nodes.size > 1 +        say "Deploying to these nodes: #{nodes.keys.join(', ')}" +        if !global[:yes] && !agree("Continue? ") +          quit! "OK. Bye."          end -        nodes = manager.filter!(args) -        ssh_connect(nodes, connect_options(options)) do |ssh| -          ssh.leap.history(lines) +      end + +      environments = nodes.field('environment').uniq +      if environments.empty? +        environments = [nil] +      end +      environments.each do |env| +        check_platform_pinning(env, global) +      end + +      # compile hiera files for all the nodes in every environment that is +      # being deployed and only those environments. +      compile_hiera_files(manager.filter(environments), false) + +      log :checking, 'nodes' do +        SSH.remote_command(nodes, options) do |ssh, host| +          begin +            ssh.scripts.check_for_no_deploy +            ssh.scripts.assert_initialized +          rescue SSH::ExecuteError +            # skip nodes with errors, but run others +            nodes.delete(host.hostname) +          end +        end +      end + +      if nodes.empty? +        return +      end + +      log :synching, "configuration files" do +        sync_hiera_config(nodes, options) +        sync_support_files(nodes, options) +      end +      log :synching, "puppet manifests" do +        sync_puppet_files(nodes, options) +      end + +      unless options[:sync] +        log :applying, "puppet" do +          SSH.remote_command(nodes, options) do |ssh, host| +            ssh.scripts.puppet_apply( +              :verbosity => [LeapCli.log_level,5].min, +              :tags => tags(options), +              :force => options[:force], +              :info => deploy_info, +              :downgrade => options[:downgrade] +            ) +          end          end        end      end -    private +    def run_history(global, options, args) +      require 'leap_cli/ssh' + +      if options[:last] == true +        lines = 1 +      else +        lines = 10 +      end +      nodes = manager.filter!(args) +      SSH.remote_command(nodes, options) do |ssh, host| +        ssh.scripts.history(lines) +      end +    end      def forcible_prompt(forced, msg, prompt)        say(msg) @@ -211,56 +233,51 @@ module LeapCli        end      end -    def sync_hiera_config(ssh) -      ssh.rsync.update do |server| -        node = manager.node(server.host) +    def sync_hiera_config(nodes, options) +      SSH.remote_sync(nodes, options) do |sync, host| +        node = manager.node(host.hostname)          hiera_file = Path.relative_path([:hiera, node.name]) -        ssh.leap.log hiera_file + ' -> ' + node.name + ':' + Leap::Platform.hiera_path -        { -          :source => hiera_file, -          :dest => Leap::Platform.hiera_path, -          :flags => "-rltp --chmod=u+rX,go-rwx" -        } +        sync.log hiera_file + ' -> ' + node.name + ':' + Leap::Platform.hiera_path +        sync.source = hiera_file +        sync.dest = Leap::Platform.hiera_path +        sync.flags = "-rltp --chmod=u+rX,go-rwx" +        sync.exec        end      end      #      # sync various support files.      # -    def sync_support_files(ssh) -      dest_dir = Leap::Platform.files_dir +    def sync_support_files(nodes, options) +      dest_dir     = Leap::Platform.files_dir        custom_files = build_custom_file_list -      ssh.rsync.update do |server| -        node = manager.node(server.host) +      SSH.remote_sync(nodes, options) do |sync, host| +        node = manager.node(host.hostname)          files_to_sync = node.file_paths.collect {|path| Path.relative_path(path, Path.provider) }          files_to_sync += custom_files          if files_to_sync.any? -          ssh.leap.log(files_to_sync.join(', ') + ' -> ' + node.name + ':' + dest_dir) -          { -            :chdir => Path.named_path(:files_dir), -            :source => ".", -            :dest => dest_dir, -            :excludes => "*", -            :includes => calculate_includes_from_files(files_to_sync, '/files'), -            :flags => "-rltp --chmod=u+rX,go-rwx --relative --delete --delete-excluded --copy-links" -          } -        else -          nil +          sync.log(files_to_sync.join(', ') + ' -> ' + node.name + ':' + dest_dir) +          sync.chdir = Path.named_path(:files_dir) +          sync.source = "." +          sync.dest = dest_dir +          sync.excludes = "*" +          sync.includes = calculate_includes_from_files(files_to_sync, '/files') +          sync.flags = "-rltp --chmod=u+rX,go-rwx --relative --delete --delete-excluded --copy-links" +          sync.exec          end        end      end -    def sync_puppet_files(ssh) -      ssh.rsync.update do |server| -        ssh.leap.log(Path.platform + '/[bin,tests,puppet] -> ' + server.host + ':' + Leap::Platform.leap_dir) -        { -          :dest => Leap::Platform.leap_dir, -          :source => '.', -          :chdir => Path.platform, -          :excludes => '*', -          :includes => ['/bin', '/bin/**', '/puppet', '/puppet/**', '/tests', '/tests/**'], -          :flags => "-rlt --relative --delete --copy-links" -        } +    def sync_puppet_files(nodes, options) +      SSH.remote_sync(nodes, options) do |sync, host| +        sync.log(Path.platform + '/[bin,tests,puppet] -> ' + host.hostname + ':' + Leap::Platform.leap_dir) +        sync.dest = Leap::Platform.leap_dir +        sync.source = '.' +        sync.chdir = Path.platform +        sync.excludes = '*' +        sync.includes = ['/bin', '/bin/**', '/puppet', '/puppet/**', '/tests', '/tests/server-tests', '/tests/server-tests/**'] +        sync.flags = "-rlt --relative --delete --copy-links" +        sync.exec        end      end @@ -269,7 +286,7 @@ module LeapCli      # repository.      #      def init_submodules -      return unless is_git_directory?(Path.platform) +      return unless is_git_directory?(Path.platform) && !is_git_subrepo?(Path.platform)        Dir.chdir Path.platform do          assert_run! "git submodule sync"          statuses = assert_run! "git submodule status" diff --git a/lib/leap_cli/commands/facts.rb b/lib/leap_cli/commands/facts.rb index 11329ccc..74ef463d 100644 --- a/lib/leap_cli/commands/facts.rb +++ b/lib/leap_cli/commands/facts.rb @@ -79,15 +79,18 @@ module LeapCli; module Commands    private    def update_facts(global_options, options, args) +    require 'leap_cli/ssh'      nodes = manager.filter(args, :local => false, :disabled => false)      new_facts = {} -    ssh_connect(nodes) do |ssh| -      ssh.leap.run_with_progress(facter_cmd) do |response| -        node = manager.node(response[:host]) +    SSH.remote_command(nodes) do |ssh, host| +      response = ssh.capture(facter_cmd, :log_output => false) +      if response +        node = manager.node(host.hostname)          if node -          new_facts[node.name] = response[:data].strip +          new_facts[node.name] = response.strip +          log 'done', :host => host.to_s          else -          log :warning, 'Could not find node for hostname %s' % response[:host] +          log :warning, 'Could not find node for hostname %s' % host          end        end      end diff --git a/lib/leap_cli/commands/info.rb b/lib/leap_cli/commands/info.rb index 52225a94..a49c20c9 100644 --- a/lib/leap_cli/commands/info.rb +++ b/lib/leap_cli/commands/info.rb @@ -5,10 +5,17 @@ module LeapCli; module Commands    arg_name 'FILTER'    command [:info] do |c|      c.action do |global,options,args| -      nodes = manager.filter!(args) -      ssh_connect(nodes, connect_options(options)) do |ssh| -        ssh.leap.debug -      end +      run_info(global, options, args) +    end +  end + +  private + +  def run_info(global, options, args) +    require 'leap_cli/ssh' +    nodes = manager.filter!(args) +    SSH.remote_command(nodes, options) do |ssh, host| +      ssh.scripts.debug      end    end diff --git a/lib/leap_cli/commands/inspect.rb b/lib/leap_cli/commands/inspect.rb index 20654fa7..b71da80e 100644 --- a/lib/leap_cli/commands/inspect.rb +++ b/lib/leap_cli/commands/inspect.rb @@ -25,27 +25,22 @@ module LeapCli; module Commands      "PEM certificate request" => :inspect_x509_csr    } +  SUFFIX_MAP = { +    ".json" => :inspect_unknown_json, +    ".key"  => :inspect_x509_key +  } +    def inspection_method(object) -    if File.exists?(object) +    if File.exist?(object)        ftype = `file #{object}`.split(':').last.strip +      suffix = File.extname(object)        log 2, "file is of type '#{ftype}'"        if FTYPE_MAP[ftype]          FTYPE_MAP[ftype] -      elsif File.extname(object) == ".json" -        full_path = File.expand_path(object, Dir.pwd) -        if path_match?(:node_config, full_path) -          :inspect_node -        elsif path_match?(:service_config, full_path) -          :inspect_service -        elsif path_match?(:tag_config, full_path) -          :inspect_tag -        elsif path_match?(:provider_config, full_path) || path_match?(:provider_env_config, full_path) -          :inspect_provider -        elsif path_match?(:common_config, full_path) -          :inspect_common -        else -          nil -        end +      elsif SUFFIX_MAP[suffix] +        SUFFIX_MAP[suffix] +      else +        nil        end      elsif manager.nodes[object]        :inspect_node @@ -72,8 +67,10 @@ module LeapCli; module Commands    end    def inspect_x509_cert(file_path, options) +    require 'leap_cli/x509'      assert_bin! 'openssl'      puts assert_run! 'openssl x509 -in %s -text -noout' % file_path +    log 0, :"SHA1 fingerprint", X509.fingerprint("SHA1", file_path)      log 0, :"SHA256 fingerprint", X509.fingerprint("SHA256", file_path)    end @@ -123,6 +120,23 @@ module LeapCli; module Commands      end    end +  def inspect_unknown_json(arg, options) +    full_path = File.expand_path(arg, Dir.pwd) +    if path_match?(:node_config, full_path) +      inspect_node(arg, options) +    elsif path_match?(:service_config, full_path) +      inspect_service(arg, options) +    elsif path_match?(:tag_config, full_path) +      inspect_tag(arg, options) +    elsif path_match?(:provider_config, full_path) || path_match?(:provider_env_config, full_path) +      inspect_provider(arg, options) +    elsif path_match?(:common_config, full_path) +      inspect_common(arg, options) +    else +      inspect_json(arg, options) +    end +  end +    #    # helpers    # diff --git a/lib/leap_cli/commands/list.rb b/lib/leap_cli/commands/list.rb index aa425432..1b3efc27 100644 --- a/lib/leap_cli/commands/list.rb +++ b/lib/leap_cli/commands/list.rb @@ -1,5 +1,3 @@ -require 'command_line_reporter' -  module LeapCli; module Commands    desc 'List nodes and their classifications' @@ -15,33 +13,38 @@ module LeapCli; module Commands      c.flag 'print', :desc => 'What attributes to print (optional)'      c.switch 'disabled', :desc => 'Include disabled nodes in the list.', :negatable => false      c.action do |global_options,options,args| -      # don't rely on default manager(), because we want to pass custom options to load() -      manager = LeapCli::Config::Manager.new -      if global_options[:color] -        colors = ['cyan', 'white'] -      else -        colors = [nil, nil] -      end -      puts -      manager.load(:include_disabled => options['disabled'], :continue_on_error => true) -      if options['print'] -        print_node_properties(manager.filter(args), options['print']) -      else -        if args.any? -          NodeTable.new(manager.filter(args), colors).run -        else -          environment = LeapCli.leapfile.environment || '_all_' -          TagTable.new('SERVICES', manager.env(environment).services, colors).run -          TagTable.new('TAGS', manager.env(environment).tags, colors).run -          NodeTable.new(manager.filter(), colors).run -        end -      end +      do_list(global_options, options, args)      end    end    private -  def self.print_node_properties(nodes, properties) +  def do_list(global, options, args) +    require 'leap_cli/util/console_table' +    # don't rely on default manager(), because we want to pass custom options to load() +    manager = LeapCli::Config::Manager.new +    if global[:color] +      colors = [:cyan, nil] +    else +      colors = [nil, nil] +    end +    puts +    manager.load(:include_disabled => options['disabled'], :continue_on_error => true) +    if options['print'] +      print_node_properties(manager.filter(args), options['print']) +    else +      if args.any? +        NodeTable.new(manager.filter(args), colors).run +      else +        environment = LeapCli.leapfile.environment || '_all_' +        TagTable.new('SERVICES', manager.env(environment).services, colors).run +        TagTable.new('TAGS', manager.env(environment).tags, colors).run +        NodeTable.new(manager.filter(), colors).run +      end +    end +  end + +  def print_node_properties(nodes, properties)      properties = properties.split(',')      max_width = nodes.keys.inject(0) {|max,i| [i.size,max].max}      nodes.each_node do |node| @@ -62,8 +65,7 @@ module LeapCli; module Commands      puts    end -  class TagTable -    include CommandLineReporter +  class TagTable < LeapCli::Util::ConsoleTable      def initialize(heading, tag_list, colors)        @heading = heading        @tag_list = tag_list @@ -71,29 +73,24 @@ module LeapCli; module Commands      end      def run        tags = @tag_list.keys.select{|tag| tag !~ /^_/}.sort # sorted list of tags, excluding _partials -      max_width = [20, (tags+[@heading]).inject(0) {|max,i| [i.size,max].max}].max -      table :border => false do -        row :color => @colors[0]  do -          column @heading, :align => 'right', :width => max_width -          column "NODES", :width => HighLine::SystemExtensions.terminal_size.first - max_width - 2, :padding => 2 +      table do +        row(color: @colors[0]) do +          column @heading, align: 'right', min_width: 20 +          column "NODES"          end          tags.each do |tag|            next if @tag_list[tag].node_list.empty? -          row :color => @colors[1] do +          row(color: @colors[1]) do              column tag              column @tag_list[tag].node_list.keys.sort.join(', ')            end          end        end -      vertical_spacing +      draw_table      end    end -  # -  # might be handy: HighLine::SystemExtensions.terminal_size.first -  # -  class NodeTable -    include CommandLineReporter +  class NodeTable < LeapCli::Util::ConsoleTable      def initialize(node_list, colors)        @node_list = node_list        @colors = colors @@ -103,29 +100,25 @@ module LeapCli; module Commands          [node_name, @node_list[node_name].services.sort.join(', '), @node_list[node_name].tags.sort.join(', ')]        end        unless rows.any? -        puts Paint["no results", :red] +        puts " = " + LeapCli.logger.colorize("no results", :red)          puts          return        end -      padding = 2 -      max_node_width    = [20, (rows.map{|i|i[0]} + ["NODES"]   ).inject(0) {|max,i| [i.size,max].max}].max -      max_service_width = (rows.map{|i|i[1]} + ["SERVICES"]).inject(0) {|max,i| [i.size+padding+padding,max].max} -      max_tag_width     = (rows.map{|i|i[2]} + ["TAGS"]    ).inject(0) {|max,i| [i.size,max].max} -      table :border => false do -        row :color => @colors[0]  do -          column "NODES", :align => 'right', :width => max_node_width -          column "SERVICES", :width => max_service_width, :padding => 2 -          column "TAGS", :width => max_tag_width +      table do +        row(color: @colors[0]) do +          column "NODES", align: 'right', min_width: 20 +          column "SERVICES" +          column "TAGS"          end          rows.each do |r| -          row :color => @colors[1] do +          row(color: @colors[1]) do              column r[0]              column r[1]              column r[2]            end          end        end -      vertical_spacing +      draw_table      end    end diff --git a/lib/leap_cli/commands/node.rb b/lib/leap_cli/commands/node.rb index a23661b3..60540de9 100644 --- a/lib/leap_cli/commands/node.rb +++ b/lib/leap_cli/commands/node.rb @@ -3,8 +3,6 @@  #      but all other `node x` commands live here.  # -autoload :IPAddr, 'ipaddr' -  module LeapCli; module Commands    ## @@ -18,33 +16,16 @@ module LeapCli; module Commands                      "The format is property_name:value.",                      "For example: `leap node add web1 ip_address:1.2.3.4 services:webapp`.",                      "To set nested properties, property name can contain '.', like so: `leap node add web1 ssh.port:44`", -                    "Separeate multiple values for a single property with a comma, like so: `leap node add mynode services:webapp,dns`"].join("\n\n") +                    "Separate multiple values for a single property with a comma, like so: `leap node add mynode services:webapp,dns`"].join("\n\n")      node.arg_name 'NAME [SEED]' # , :optional => false, :multiple => false      node.command :add do |add| -      add.switch :local, :desc => 'Make a local testing node (by automatically assigning the next available local IP address). Local nodes are run as virtual machines on your computer.', :negatable => false +      add.switch :local, :desc => 'Make a local testing node (by assigning the next available local IP address). Local nodes are run as virtual machines on your computer.', :negatable => false +      add.switch :vm, :desc => 'Make a remote virtual machine for this node. Requires a valid cloud.json configuration.', :negatable => false        add.action do |global_options,options,args| -        # argument sanity checks -        name = args.first -        assert_valid_node_name!(name, options[:local]) -        assert_files_missing! [:node_config, name] - -        # create and seed new node -        node = Config::Node.new(manager.env) -        if options[:local] -          node['ip_address'] = pick_next_vagrant_ip_address -        end -        seed_node_data_from_cmd_line(node, args[1..-1]) -        seed_node_data_from_template(node) -        validate_ip_address(node) -        begin -          node['name'] = name -          json = node.dump_json(:exclude => ['name']) -          write_file!([:node_config, name], json + "\n") -          if file_exists? :ca_cert, :ca_key -            generate_cert_for_node(manager.reload_node!(node)) -          end -        rescue LeapCli::ConfigError => exc -          remove_node_files(name) +        if options[:vm] +          do_vm_add(global_options, options, args) +        else +          do_node_add(global_options, options, args)          end        end      end @@ -53,15 +34,7 @@ module LeapCli; module Commands      node.arg_name 'OLD_NAME NEW_NAME'      node.command :mv do |mv|        mv.action do |global_options,options,args| -        node = get_node_from_args(args, include_disabled: true) -        new_name = args.last -        assert_valid_node_name!(new_name, node.vagrant?) -        ensure_dir [:node_files_dir, new_name] -        Leap::Platform.node_files.each do |path| -          rename_file! [path, node.name], [path, new_name] -        end -        remove_directory! [:node_files_dir, node.name] -        rename_node_facts(node.name, new_name) +        do_node_move(global_options, options, args)        end      end @@ -69,12 +42,7 @@ module LeapCli; module Commands      node.arg_name 'NAME' #:optional => false #, :multiple => false      node.command :rm do |rm|        rm.action do |global_options,options,args| -        node = get_node_from_args(args, include_disabled: true) -        remove_node_files(node.name) -        if node.vagrant? -          vagrant_command("destroy --force", [node.name]) -        end -        remove_node_facts(node.name) +        do_node_rm(global_options, options, args)        end      end    end @@ -93,96 +61,69 @@ module LeapCli; module Commands      node    end -  def seed_node_data_from_cmd_line(node, args) -    args.each do |seed| -      key, value = seed.split(':', 2) -      value = format_seed_value(value) -      assert! key =~ /^[0-9a-z\._]+$/, "illegal characters used in property '#{key}'" -      if key =~ /\./ -        key_parts = key.split('.') -        final_key = key_parts.pop -        current_object = node -        key_parts.each do |key_part| -          current_object[key_part] ||= Config::Object.new -          current_object = current_object[key_part] -        end -        current_object[final_key] = value -      else -        node[key] = value -      end -    end -  end +  protected    # -  # load "new node template" information into the `node`, modifying `node`. -  # values in the template will not override existing node values. +  # additionally called by `leap vm add`    # -  def seed_node_data_from_template(node) -    node.inherit_from!(manager.template('common')) -    [node['services']].flatten.each do |service| -      if service -        template = manager.template(service) -        if template -          node.inherit_from!(template) -        end -      end +  def do_node_add(global, options, args) +    name = args.first +    unless global[:force] +      assert_files_missing! [:node_config, name]      end -  end - -  def remove_node_files(node_name) -    (Leap::Platform.node_files + [:node_files_dir]).each do |path| -      remove_file! [path, node_name] +    node = Config::Node.new(manager.env) +    node['name'] = name +    if options[:ip_address] +      node['ip_address'] = options[:ip_address] +    elsif options[:local] +      node['ip_address'] = pick_next_vagrant_ip_address      end +    node.seed_from_args(args[1..-1]) +    node.seed_from_template +    node.validate! +    node.write_configs +    # reapply inheritance, since tags/services might have changed: +    node = manager.reload_node!(node) +    node.generate_cert    end -  # -  # conversions: -  # -  #   "x,y,z" => ["x","y","z"] -  # -  #   "22" => 22 -  # -  #   "5.1" => 5.1 -  # -  def format_seed_value(v) -    if v =~ /,/ -      v = v.split(',') -      v.map! do |i| -        i = i.to_i if i.to_i.to_s == i -        i = i.to_f if i.to_f.to_s == i -        i -      end -    else -      v = v.to_i if v.to_i.to_s == v -      v = v.to_f if v.to_f.to_s == v -    end -    return v -  end +  private -  def validate_ip_address(node) -    if node['ip_address'] == "REQUIRED" -      bail! do -        log :error, "ip_address is not set. Specify with `leap node add NAME ip_address:ADDRESS`." -      end +  def do_node_move(global, options, args) +    node = get_node_from_args(args, include_disabled: true) +    new_name = args.last +    Config::Node.validate_name!(new_name, node.vagrant?) +    ensure_dir [:node_files_dir, new_name] +    Leap::Platform.node_files.each do |path| +      rename_file! [path, node.name], [path, new_name]      end -    IPAddr.new(node['ip_address']) -  rescue ArgumentError -    bail! do -      if node['ip_address'] -        log :invalid, "ip_address #{node['ip_address'].inspect}" -      else -        log :missing, "ip_address" -      end +    remove_directory! [:node_files_dir, node.name] +    rename_node_facts(node.name, new_name) +    if node.vm_id? +      node['name'] = new_name +      bind_server_to_node(node.vm.id, node, options)      end    end -  def assert_valid_node_name!(name, local=false) -    assert! name, 'No <node-name> specified.' -    if local -      assert! name =~ /^[0-9a-z]+$/, "illegal characters used in node name '#{name}' (note: Vagrant does not allow hyphens or underscores)" -    else -      assert! name =~ /^[0-9a-z-]+$/, "illegal characters used in node name '#{name}' (note: Linux does not allow underscores)" +  def do_node_rm(global, options, args) +    node = get_node_from_args(args, include_disabled: true) +    if node.vm? +      if !node.vm_id? +        log :warning, "The node #{node.name} is missing a 'vm.id' property. "+ +                      "You may have a virtual machine instance that is left "+ +                      "running. Check `leap vm status`" +      else +        msg = "The node #{node.name} appears to be associated with a virtual machine. " + +              "Do you want to also destroy this virtual machine? " +        if global[:yes] || agree(msg) +          do_vm_rm(global, options, args) +        end +      end +    elsif node.vagrant? +      vagrant_command("destroy --force", [node.name])      end +    node.remove_files +    remove_node_facts(node.name)    end  end; end diff --git a/lib/leap_cli/commands/node_init.rb b/lib/leap_cli/commands/node_init.rb index 33f6288d..59661295 100644 --- a/lib/leap_cli/commands/node_init.rb +++ b/lib/leap_cli/commands/node_init.rb @@ -6,58 +6,70 @@  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, " + +  command :node do |cmd| +    cmd.desc 'Bootstraps a node or nodes, setting up SSH keys and installing prerequisite packages' +    cmd.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 +    cmd.arg_name 'FILTER' +    cmd.command :init do |init| +      #init.switch 'echo', :desc => 'If set, passwords are visible as you type them (default is hidden)', :negatable => false +      # ^^ i am not sure how to get this working with sshkit        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 -            unless node.vagrant? -              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 -            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 +        run_node_init(global, options, args) +      end +    end +  end + +  private + +  def run_node_init(global, options, args) +    require 'leap_cli/ssh' +    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 +      # allow password auth for new nodes: +      options[:auth_methods] = ["publickey", "password"] +      if node.vm? +        # new AWS virtual machines will only allow login as 'admin' +        # before we continue, we must enable root access. +        SSH.remote_command(node, options.merge(:user => 'admin')) do |ssh, host| +          ssh.scripts.allow_root_ssh +        end +      end +      SSH.remote_command(node, options) do |ssh, host| +        if node.vagrant? +          ssh.scripts.install_insecure_vagrant_key +        end +        ssh.scripts.install_authorized_keys +        ssh.scripts.install_prerequisites +        unless node.vagrant? +          ssh.log(:checking, "SSH host keys") do +            response = ssh.capture(get_ssh_keys_cmd, :log_output => false) +            if response +              update_local_ssh_host_keys(node, response)              end            end -          finished << node.name          end -        log :completed, "initialization of nodes #{finished.join(', ')}" +        ssh.log(:updating, "facts") do +          response = ssh.capture(facter_cmd) +          if response +            update_node_facts(node.name, response) +          end +        end        end +      finished << node.name      end +    log :completed, "initialization of nodes #{finished.join(', ')}"    end -  private -    ##    ## PRIVATE HELPERS    ## @@ -83,7 +95,7 @@ module LeapCli; module Commands      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) +      if host_keys.include? SSH::Key.load(pub_key_path)          log :trusted, "- Public SSH host key for #{node.name} matches previously saved key", :indent => 1        else          bail! do @@ -96,7 +108,7 @@ module LeapCli; module Commands        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) +        public_key = SSH::Key.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 @@ -118,7 +130,7 @@ module LeapCli; module Commands    #    # Get the public host keys for a host using ssh-keyscan. -  # Return an array of SshKey objects, one for each key. +  # Return an array of SSH::Key objects, one for each key.    #    def get_public_keys_for_ip(address, port=22)      assert_bin!('ssh-keyscan') @@ -130,7 +142,7 @@ module LeapCli; module Commands      if output =~ /No route to host/        bail! :failed, 'ssh-keyscan: no route to %s' % address      else -      keys = SshKey.parse_keys(output) +      keys = SSH::Key.parse_keys(output)        if keys.empty?          bail! "ssh-keyscan got zero host keys back (that we understand)! Output was: #{output}"        else @@ -139,7 +151,7 @@ module LeapCli; module Commands      end    end -  # run on the server to generate a string suitable for passing to SshKey.parse_keys() +  # run on the server to generate a string suitable for passing to SSH::Key.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 @@ -149,10 +161,10 @@ module LeapCli; module Commands    # 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) +    remote_keys = SSH::Key.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) +    current_key = SSH::Key.load(Path.named_path([:node_ssh_pub_key, node.name])) +    best_key = SSH::Key.pick_best_key(remote_keys)      return unless best_key && current_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.") diff --git a/lib/leap_cli/commands/open.rb b/lib/leap_cli/commands/open.rb new file mode 100644 index 00000000..3de97298 --- /dev/null +++ b/lib/leap_cli/commands/open.rb @@ -0,0 +1,103 @@ +module LeapCli +  module Commands + +    desc 'Opens useful URLs in a web browser.' +    long_desc "NAME can be one or more of: monitor, web, docs, bug" +    arg_name 'NAME' +    command :open do |c| +      c.flag :env, :desc => 'Which environment to use (optional).', :arg_name => 'ENVIRONMENT' +      c.switch :ip, :desc => 'To get around HSTS or DNS, open the URL using the IP address instead of the domain (optional).' +      c.action do |global_options,options,args| +        do_open_cmd(global_options, options, args) +      end +    end + +    private + +    def do_open_cmd(global, options, args) +      env = options[:env] || LeapCli.leapfile.environment +      args.each do |name| +        if name == 'monitor' || name == 'nagios' +          open_nagios(env, options[:ip]) +        elsif name == 'web' || name == 'webapp' +          open_webapp(env, options[:ip]) +        elsif name == 'docs' || name == 'help' || name == 'doc' +          open_url("https://leap.se/docs") +        elsif name == 'bug' || name == 'feature' || name == 'bugreport' +          open_url("https://leap.se/code") +        else +          bail! "'#{name}' is not a recognized URL." +        end +      end +    end + +    def find_node_with_service(service, environment) +      nodes = manager.nodes[:services => service] +      node = nil +      if nodes.size == 0 +        bail! "No nodes with '#{service}' service." +      elsif nodes.size == 1 +        node = nodes.values.first +      elsif nodes.size > 1 +        if environment +          node = nodes[:environment => environment].values.first +          if node.nil? +            bail! "No nodes with '#{service}' service." +          end +        else +          node_list = nodes.values +          list = node_list.map {|i| "#{i.name} (#{i.environment})"} +          index = numbered_choice_menu("Which #{service}?", list) do |line, i| +            say("#{i+1}. #{line}") +          end +          node = node_list[index] +        end +      end +      return node +    end + +    def pick_domain(node, ip) +      bail! "monitor missing webapp service" unless node["webapp"] +      if ip +        domain = node["ip_address"] +      else +        domain = node["webapp"]["domain"] +        bail! "webapp domain is missing" unless !domain.empty? +      end +      return domain +    end + +    def open_webapp(environment, ip) +      node = find_node_with_service('webapp', environment) +      domain = pick_domain(node, ip) +      open_url("https://%s" % domain) +    end + +    def open_nagios(environment, ip) +      node = find_node_with_service('monitor', environment) +      domain = pick_domain(node, ip) +      username = 'nagiosadmin' +      password = manager.secrets.retrieve("nagios_admin_password", node.environment) +      bail! "unable to find nagios_admin_password" unless !password.nil? && !password.empty? +      open_url("https://%s:%s@%s/nagios3" % [username, password, domain]) +    end + +    def open_url(url) +      log :opening, url +      if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/ +        system %(start "#{url}") +      elsif RbConfig::CONFIG['host_os'] =~ /darwin/ +        system %(open "#{url}") +      elsif RbConfig::CONFIG['host_os'] =~ /linux|bsd/ +        ['xdg-open', 'sensible-browser', 'gnome-open', 'kde-open'].each do |cmd| +          if !`which #{cmd}`.strip.empty? +            system %(#{cmd} "#{url}") +            return +          end +        end +        log :error, 'no command found to launch browser window.' +      end +    end + +  end +end
\ No newline at end of file diff --git a/lib/leap_cli/commands/run.rb b/lib/leap_cli/commands/run.rb new file mode 100644 index 00000000..cad9b7a0 --- /dev/null +++ b/lib/leap_cli/commands/run.rb @@ -0,0 +1,50 @@ +module LeapCli; module Commands + +  desc 'Run a shell command remotely' +  long_desc "Runs the specified command COMMAND on each node in the FILTER set. " + +            "For example, `leap run 'uname -a' webapp`" +  arg_name 'COMMAND FILTER' +  command :run do |c| +    c.switch 'stream', :default => false, :desc => 'If set, stream the output as it arrives. (default: --stream for a single node, --no-stream for multiple nodes)' +    c.flag 'port', :arg_name => 'SSH_PORT', :desc => 'Override default SSH port used when trying to connect to the server.' +    c.action do |global, options, args| +      run_shell_command(global, options, args) +    end +  end + +  private + +  def run_shell_command(global, options, args) +    require 'leap_cli/ssh' +    cmd    = args[0] +    filter = args[1..-1] +    cmd    = global[:force] ? cmd : LeapCli::SSH::Options.sanitize_command(cmd) +    nodes  = manager.filter!(filter) +    if nodes.size == 1 || options[:stream] +      stream_command(nodes, cmd, options) +    else +      capture_command(nodes, cmd, options) +    end +  end + +  def capture_command(nodes, cmd, options) +    SSH.remote_command(nodes, options) do |ssh, host| +      output = ssh.capture(cmd, :log_output => false) +      if output +        logger = LeapCli.new_logger +        logger.log(:ran, "`" + cmd + "`", host: host.hostname, color: :green) do +          logger.log(output, wrap: true) +        end +      end +    end +  end + +  def stream_command(nodes, cmd, options) +    SSH.remote_command(nodes, options) do |ssh, host| +      ssh.stream(cmd, :log_cmd => true, :log_finish => true, :fail_msg => 'oops') +    end +  end + +end; end + + diff --git a/lib/leap_cli/commands/ssh.rb b/lib/leap_cli/commands/ssh.rb index 3887618e..03192071 100644 --- a/lib/leap_cli/commands/ssh.rb +++ b/lib/leap_cli/commands/ssh.rb @@ -69,20 +69,6 @@ module LeapCli; module Commands    protected -  # -  # allow for ssh overrides of all commands that use ssh_connect -  # -  def connect_options(options) -    connect_options = {:ssh_options=>{}} -    if options[:port] -      connect_options[:ssh_options][:port] = options[:port] -    end -    if options[:ip] -      connect_options[:ssh_options][:host_name] = options[:ip] -    end -    return connect_options -  end -    def ssh_config_help_message      puts ""      puts "Are 'too many authentication failures' getting you down?" @@ -193,7 +179,8 @@ module LeapCli; module Commands        "-o 'UserKnownHostsFile=/dev/null'"      ]      if node.vagrant? -      options << "-i #{vagrant_ssh_key_file}"    # use the universal vagrant insecure key +      # use the universal vagrant insecure key: +      options << "-i #{LeapCli::Util::Vagrant.vagrant_ssh_key_file}"        options << "-o IdentitiesOnly=yes"         # force the use of the insecure vagrant key        options << "-o 'StrictHostKeyChecking=no'" # blindly accept host key and don't save it                                                   # (since userknownhostsfile is /dev/null) diff --git a/lib/leap_cli/commands/test.rb b/lib/leap_cli/commands/test.rb index 73207b31..70eb00fd 100644 --- a/lib/leap_cli/commands/test.rb +++ b/lib/leap_cli/commands/test.rb @@ -7,24 +7,7 @@ module LeapCli; module Commands      test.command :run do |run|        run.switch 'continue', :desc => 'Continue over errors and failures (default is --no-continue).', :negatable => true        run.action do |global_options,options,args| -        test_order = File.join(Path.platform, 'tests/order.rb') -        if File.exists?(test_order) -          require test_order -        end -        manager.filter!(args).names_in_test_dependency_order.each do |node_name| -          node = manager.nodes[node_name] -          begin -            ssh_connect(node) do |ssh| -              ssh.run(test_cmd(options)) -            end -          rescue Capistrano::CommandError => exc -            if options[:continue] -              exit_status(1) -            else -              bail! -            end -          end -        end +        do_test_run(global_options, options, args)        end      end @@ -40,6 +23,28 @@ module LeapCli; module Commands    private +  def do_test_run(global_options, options, args) +    require 'leap_cli/ssh' +    test_order = File.join(Path.platform, 'tests/order.rb') +    if File.exist?(test_order) +      require test_order +    end +    manager.filter!(args).names_in_test_dependency_order.each do |node_name| +      node = manager.nodes[node_name] +      begin +        SSH::remote_command(node, options) do |ssh, host| +          ssh.stream(test_cmd(options), :raise_error => true, :log_wrap => true) +        end +      rescue LeapCli::SSH::ExecuteError +        if options[:continue] +          exit_status(1) +        else +          bail! +        end +      end +    end +  end +    def test_cmd(options)      if options[:continue]        "#{Leap::Platform.leap_dir}/bin/run_tests --continue" diff --git a/lib/leap_cli/commands/user.rb b/lib/leap_cli/commands/user.rb index b842e854..1ca92719 100644 --- a/lib/leap_cli/commands/user.rb +++ b/lib/leap_cli/commands/user.rb @@ -13,67 +13,131 @@  module LeapCli    module Commands -    desc 'Adds a new trusted sysadmin by adding public keys to the "users" directory.' -    arg_name 'USERNAME' #, :optional => false, :multiple => false +    desc 'Manage trusted sysadmins (DEPRECATED)' +    long_desc "Use `leap user add` instead"      command :'add-user' do |c| -        c.switch 'self', :desc => 'Add yourself as a trusted sysadmin by choosing among the public keys available for the current user.', :negatable => false        c.flag 'ssh-pub-key', :desc => 'SSH public key file for this new user'        c.flag 'pgp-pub-key', :desc => 'OpenPGP public key file for this new user' -        c.action do |global_options,options,args| -        username = args.first -        if !username.any? -          if options[:self] -            username ||= `whoami`.strip -          else -            help! "Either USERNAME argument or --self flag is required." -          end -        end -        if Leap::Platform.reserved_usernames.include? username -          bail! %(The username "#{username}" is reserved. Sorry, pick another.) -        end +        do_add_user(global_options, options, args) +      end +    end -        ssh_pub_key = nil -        pgp_pub_key = nil +    desc 'Manage trusted sysadmins' +    long_desc "Manage the trusted sysadmins that are configured in the 'users' directory." +    command :user do |user| + +      user.desc 'Adds a new trusted sysadmin' +      user.arg_name 'USERNAME' +      user.command :add do |c| +        c.switch 'self', :desc => 'Add yourself as a trusted sysadmin by choosing among the public keys available for the current user.', :negatable => false +        c.flag 'ssh-pub-key', :desc => 'SSH public key file for this new user' +        c.flag 'pgp-pub-key', :desc => 'OpenPGP public key file for this new user' +        c.action do |global_options,options,args| +          do_add_user(global_options, options, args) +        end +      end -        if options['ssh-pub-key'] -          ssh_pub_key = read_file!(options['ssh-pub-key']) +      user.desc 'Removes a trusted sysadmin' +      user.arg_name 'USERNAME' +      user.command :rm do |c| +        c.action do |global_options,options,args| +          do_rm_user(global_options, options, args)          end -        if options['pgp-pub-key'] -          pgp_pub_key = read_file!(options['pgp-pub-key']) +      end + +      user.desc 'Lists the configured sysadmins' +      user.command :ls do |c| +        c.action do |global_options,options,args| +          do_list_users(global_options, options, args)          end +      end + +    end +    private + +    def do_add_user(global, options, args) +      require 'leap_cli/ssh' + +      username = args.first +      if !username.any?          if options[:self] -          ssh_pub_key ||= pick_ssh_key.to_s -          pgp_pub_key ||= pick_pgp_key +          username ||= `whoami`.strip +        else +          help! "Either USERNAME argument or --self flag is required."          end +      end +      if Leap::Platform.reserved_usernames.include? username +        bail! %(The username "#{username}" is reserved. Sorry, pick another.) +      end -        assert!(ssh_pub_key, 'Sorry, could not find SSH public key.') +      ssh_pub_key = nil +      pgp_pub_key = nil -        if ssh_pub_key -          write_file!([:user_ssh, username], ssh_pub_key) -        end -        if pgp_pub_key -          write_file!([:user_pgp, username], pgp_pub_key) -        end +      if options['ssh-pub-key'] +        ssh_pub_key = read_file!(options['ssh-pub-key']) +      end +      if options['pgp-pub-key'] +        pgp_pub_key = read_file!(options['pgp-pub-key']) +      end +      if options[:self] +        ssh_pub_key ||= pick_ssh_key.to_s +        pgp_pub_key ||= pick_pgp_key +      end + +      assert!(ssh_pub_key, 'Sorry, could not find SSH public key.') + +      if ssh_pub_key +        write_file!([:user_ssh, username], ssh_pub_key) +      end +      if pgp_pub_key +        write_file!([:user_pgp, username], pgp_pub_key) +      end + +      update_authorized_keys +    end + +    def do_rm_user(global, options, args) +      dir = [:user_dir, args.first] +      if Util.dir_exists?(dir) +        Util.remove_file!(dir)          update_authorized_keys +      else +        bail! :error, 'There is no directory `%s`' % Path.named_path(dir) +      end +    end + +    def do_list_users(global, options, args) +      require 'leap_cli/ssh' + +      Dir.glob(path([:user_ssh, '*'])).each do |keyfile| +        username = File.basename(File.dirname(keyfile)) +        log username, :color => :cyan do +          log Path.relative_path(keyfile) +          key = SSH::Key.load(keyfile) +          log 'SSH MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex) +          log 'SSH SHA256 fingerprint: ' + key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64) +          log 'DER MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex) +        end        end      end      # -    # let the the user choose among the ssh public keys that we encounter, or just pick the key if there is only one. +    # let the the user choose among the ssh public keys that we encounter, or +    # just pick the key if there is only one.      #      def pick_ssh_key        ssh_keys = []        Dir.glob("#{ENV['HOME']}/.ssh/*.pub").each do |keyfile| -        ssh_keys << SshKey.load(keyfile) +        ssh_keys << SSH::Key.load(keyfile)        end        if `which ssh-add`.strip.any?          `ssh-add -L 2> /dev/null`.split("\n").compact.each do |line| -          key = SshKey.load(line) +          key = SSH::Key.load(line)            if key              key.comment = 'ssh-agent'              ssh_keys << key unless ssh_keys.include?(key) diff --git a/lib/leap_cli/commands/util.rb b/lib/leap_cli/commands/util.rb deleted file mode 100644 index c1da570e..00000000 --- a/lib/leap_cli/commands/util.rb +++ /dev/null @@ -1,50 +0,0 @@ -module LeapCli; module Commands - -  extend self -  extend LeapCli::Util -  extend LeapCli::Util::RemoteCommand - -  def path(name) -    Path.named_path(name) -  end - -  # -  # keeps prompting the user for a numbered choice, until they pick a good one or bail out. -  # -  # block is yielded and is responsible for rendering the choices. -  # -  def numbered_choice_menu(msg, items, &block) -    while true -      say("\n" + msg + ':') -      items.each_with_index &block -      say("q. quit") -      index = ask("number 1-#{items.length}> ") -      if index.empty? -        next -      elsif index =~ /q/ -        bail! -      else -        i = index.to_i - 1 -        if i < 0 || i >= items.length -          bail! -        else -          return i -        end -      end -    end -  end - - -  def parse_node_list(nodes) -    if nodes.is_a? Config::Object -      Config::ObjectList.new(nodes) -    elsif nodes.is_a? Config::ObjectList -      nodes -    elsif nodes.is_a? String -      manager.filter!(nodes) -    else -      bail! "argument error" -    end -  end - -end; end diff --git a/lib/leap_cli/commands/vagrant.rb b/lib/leap_cli/commands/vagrant.rb index 9fdd48e3..f8a75b61 100644 --- a/lib/leap_cli/commands/vagrant.rb +++ b/lib/leap_cli/commands/vagrant.rb @@ -4,7 +4,7 @@ require 'fileutils'  module LeapCli; module Commands    desc "Manage local virtual machines." -  long_desc "This command provides a convient way to manage Vagrant-based virtual machines. If FILTER argument is missing, the command runs on all local virtual machines. The Vagrantfile is automatically generated in 'test/Vagrantfile'. If you want to run vagrant commands manually, cd to 'test'." +  long_desc "This command provides a convenient way to manage Vagrant-based virtual machines. If FILTER argument is missing, the command runs on all local virtual machines. The Vagrantfile is automatically generated in 'test/Vagrantfile'. If you want to run vagrant commands manually, cd to 'test'."    command [:local, :l] do |local|      local.desc 'Starts up the virtual machine(s)'      local.arg_name 'FILTER', :optional => true #, :multiple => false @@ -35,7 +35,7 @@ module LeapCli; module Commands      local.desc 'Destroys the virtual machine(s), reclaiming the disk space'      local.arg_name 'FILTER', :optional => true #, :multiple => false -    local.command :destroy do |destroy| +    local.command [:rm, :destroy] do |destroy|        destroy.action do |global_options,options,args|          if global_options[:yes]            vagrant_command("destroy --force", args) @@ -47,7 +47,7 @@ module LeapCli; module Commands      local.desc 'Print the status of local virtual machine(s)'      local.arg_name 'FILTER', :optional => true #, :multiple => false -    local.command :status do |status| +    local.command [:ls, :status] do |status|        status.action do |global_options,options,args|          vagrant_command("status", args)        end @@ -70,25 +70,6 @@ module LeapCli; module Commands      end    end -  public - -  # -  # returns the path to a vagrant ssh private key file. -  # -  # if the vagrant.key file is owned by root or ourselves, then -  # we need to make sure that it owned by us and not world readable. -  # -  def vagrant_ssh_key_file -    file_path = Path.vagrant_ssh_priv_key_file -    Util.assert_files_exist! file_path -    uid = File.new(file_path).stat.uid -    if uid == 0 || uid == Process.euid -      FileUtils.install file_path, '/tmp/vagrant.key', :mode => 0600 -      file_path = '/tmp/vagrant.key' -    end -    return file_path -  end -    protected    def vagrant_command(cmds, args, options={}) diff --git a/lib/leap_cli/commands/vm.rb b/lib/leap_cli/commands/vm.rb new file mode 100644 index 00000000..790774f1 --- /dev/null +++ b/lib/leap_cli/commands/vm.rb @@ -0,0 +1,467 @@ +module LeapCli; module Commands + +  desc "Manage remote virtual machines (VMs)." +  long_desc "This command provides a convenient way to manage virtual machines. " + +            "FILTER may be a node filter or the ID of a virtual machine." + +  command [:vm] do |vm| +    vm.switch :mock, :desc => "Run as simulation, without actually connecting to a cloud provider. If set, --auth is ignored." +    vm.switch :wait, :desc => "Wait for servers to start/stop before continuing." +    vm.flag :auth,  :arg_name => 'AUTH', +      :desc => "Choose which authentication credentials to use from the file cloud.json. "+ +               "If omitted, will default to the node's `vm.auth` property, or the first credentials in cloud.json" + +    vm.desc "Allocates a new VM and/or associates it with node NAME." +    vm.long_desc "If node configuration file does not yet exist, "+ +            "it is created with the optional SEED values. "+ +            "You can run this command when the virtual machine already exists "+ +            "in order to update the node's `vm.id` property." +    vm.arg_name 'NODE_NAME [SEED]' +    vm.command :add do |cmd| +      cmd.action do |global, options, args| +        do_vm_add(global, options, args) +      end +    end + +    vm.desc 'Starts one or more VMs' +    vm.arg_name 'FILTER', :optional => true +    vm.command :start do |start| +      start.action do |global, options, args| +        do_vm_start(global, options, args) +      end +    end + +    vm.desc 'Shuts down one or more VMs' +    vm.long_desc 'This keeps the storage allocated. To save resources, run `leap vm rm` instead.' +    vm.arg_name 'FILTER', :optional => true +    vm.command :stop do |stop| +      stop.action do |global, options, args| +        do_vm_stop(global, options, args) +      end +    end + +    vm.desc 'Destroys one or more VMs' +    vm.arg_name 'FILTER', :optional => true +    vm.command :rm do |rm| +      rm.action do |global, options, args| +        do_vm_rm(global, options, args) +      end +    end + +    vm.desc 'Print the status of all VMs' +    vm.arg_name 'FILTER', :optional => true +    vm.command [:status, :ls] do |status| +      status.action do |global, options, args| +        do_vm_status(global, options, args) +      end +    end + +    vm.desc "Binds a running VM instance to a node configuration." +    vm.long_desc "Afterwards, the VM will be assigned a label matching the node name, "+ +            "and the node config will be updated with the instance ID." +    vm.arg_name 'NODE_NAME INSTANCE_ID' +    vm.command 'bind' do |cmd| +      cmd.action do |global, options, args| +        do_vm_bind(global, options, args) +      end +    end + +    vm.desc "Registers a SSH public key for use when creating new VMs." +    vm.long_desc "Note that only people who are creating new VM instances need to "+ +            "have their key registered." +    vm.command 'key-register' do |cmd| +      cmd.action do |global, options, args| +        do_vm_key_register(global, options, args) +      end +    end + +    vm.desc "Lists the registered SSH public keys for a particular VM provider." +    vm.command 'key-list' do |cmd| +      cmd.action do |global, options, args| +        do_vm_key_list(global, options, args) +      end +    end + +    #vm.desc 'Saves the current state of the virtual machine as a new snapshot.' +    #vm.arg_name 'FILTER', :optional => true +    #vm.command :save do |save| +    #  save.action do |global, options, args| +    #    do_vm_save(global, options, args) +    #  end +    #end + +    #vm.desc 'Resets virtual machine(s) to the last saved snapshot' +    #vm.arg_name 'FILTER', :optional => true +    #vm.command :reset do |reset| +    #  reset.action do |global, options, args| +    #    do_vm_reset(global, options, args) +    #  end +    #end + +    #vm.desc 'Lists the available images.' +    #vm.command 'image-list' do |cmd| +    #  cmd.action do |global, options, args| +    #    do_vm_image_list(global, options, args) +    #  end +    #end +  end + +  ## +  ## SHARED UTILITY METHODS +  ## + +  protected + +  # +  # a callback used if we need to upload a new ssh key +  # +  def choose_ssh_key_for_upload(cloud) +    puts +    bail! unless agree("The cloud provider `#{cloud.name}` does not have "+ +          "your public key. Do you want to upload one? ") +    key = pick_ssh_key +    username = ask("username? ", :default => `whoami`.strip) +    assert!(username && !username.empty? && username =~ /[0-9a-z_-]+/, "Username must consist of one or more letters or numbers") +    puts +    return username, key +  end + +  def bind_server_to_node(vm_id, node, options={}) +    cloud  = new_cloud_handle(node, options) +    server = cloud.compute.servers.get(vm_id) +    assert! server, "Could not find a VM instance with ID '#{vm_id}'" +    cloud.bind_server_to_node(server) +  end + +  ## +  ## COMMANDS +  ## + +  protected + +  # +  # entirely removes the vm, not just stopping it. +  # +  # This might be additionally called by the 'leap node rm' command. +  # +  def do_vm_rm(global, options, args) +    servers_from_args(global, options, args) do |cloud, server| +      cloud.unbind_server_from_node(server) if cloud.node +      destroy_server(server, options[:wait]) +    end +  end + +  private + +  def do_vm_status(global, options, args) +    cloud = new_cloud_handle(nil, options) +    servers = cloud.compute.servers + +    # +    # PRETTY TABLE +    # +    t = LeapCli::Util::ConsoleTable.new +    t.table do +      t.row(color: :cyan) do +        t.column "ID" +        t.column "NODE" +        t.column "STATE" +        t.column "FLAVOR" +        t.column "IP" +        t.column "ZONE" +      end +      servers.each do |server| +        t.row do +          t.column server.id +          t.column server.tags["node_name"] +          t.column server.state, :color => state_color(server.state) +          t.column server.flavor_id +          t.column server.public_ip_address +          t.column server.availability_zone +        end +      end +    end +    puts +    t.draw_table + +    # +    # SANITY CHECKS +    # +    servers.each do |server| +      name = server.tags["node_name"] +      if name +        node = manager.nodes[name] +        if node.nil? +          log :warning, 'A virtual machine has the name `%s`, but there is no corresponding node definition in `%s`.' % [ +            name, relative_path(path([:node_config, name]))] +          next +        end +        if node['vm'].nil? +          log :warning, 'Node `%s` is not configured as a virtual machine' % name do +            log 'You should fix this with `leap vm bind %s %s`' % [name, server.id] +          end +          next +        end +        if node['vm.id'] != server.id +          message = 'Node `%s` is configured with virtual machine id `%s`' % [name, node['vm.id']] +          log :warning, message do +            log 'But the virtual machine with that name really has id `%s`' % server.id +            log 'You should fix this with `leap vm bind %s %s`' % [name, server.id] +          end +        end +        if server.state == 'running' +          if node.ip_address != server.public_ip_address +            message = 'The configuration file for node `%s` has IP address `%s`' % [name, node.ip_address] +            log(:warning, message) do +              log 'But the virtual machine actually has IP address `%s`' % server.public_ip_address +              log 'You should fix this with `leap vm add %s`' % name +            end +          end +        end +      end +    end +    manager.filter(['vm']).each_node do |node| +      if node['vm.id'].nil? +        log :warning, 'The node `%s` is missing a server id' % node.name +        next +      end +      if !servers.detect {|s| s.id == node.vm.id } +        message = "The configuration file for node `%s` has virtual machine id of `%s`" % [node.name, node.vm.id] +        log :warning, message do +          log "But that does not match any actual virtual machines!" +        end +      end +      if !servers.detect {|s| s.tags["node_name"] == node.name } +        log :warning, "The node `%s` has no virtual machines with a matching name." % node.name do +          server = servers.detect {|s| s.id == node.vm.id } +          if server +            log 'Run `leap bind %s %s` to fix this' % [node.name, server.id] +          end +        end +      end +    end +  end + +  def do_vm_add(global, options, args) +    name = args.first +    if manager.nodes[name].nil? +      do_node_add(global, {:ip_address => '0.0.0.0'}.merge(options), args) +    end +    node   = manager.nodes[name] +    cloud  = new_cloud_handle(node, options) +    server = cloud.fetch_or_create_server(:choose_ssh_key => method(:choose_ssh_key_for_upload)) + +    if server +      cloud.bind_server_to_node(server) +      ssh_host_key = cloud.wait_for_ssh_host_key(server) +      if ssh_host_key.nil? +        log :warning, "We could not get a SSH host key." do +          log "Try running `leap vm add #{node.name}` again later." +        end +      else +        log :saving, "SSH host key for #{node.name}" +        write_file! [:node_ssh_pub_key, node.name], ssh_host_key.to_s +      end +      log "done", :color => :green, :style => :bold +    end +  end + +  def do_vm_start(global, options, args) +    servers_from_args(global, options, args) do |cloud, server| +      start_server(server, options[:wait]) +    end +  end + +  def do_vm_stop(global, options, args) +    servers_from_args(global, options, args) do |cloud, server| +      stop_server(server, options[:wait]) +    end +  end + +  def do_vm_key_register(global, options, args) +    cloud = new_cloud_handle(nil, options) +    cloud.find_or_create_key_pair(method(:choose_ssh_key_for_upload)) +  end + +  def do_vm_key_list(global, options, args) +    require 'leap_cli/ssh' +    cloud = new_cloud_handle(nil, options) +    cloud.compute.key_pairs.each do |key_pair| +      log key_pair.name, :color => :cyan do +        log "AWS fingerprint: " + key_pair.fingerprint +        key_pair, local_key = cloud.match_ssh_key(:key_pair => key_pair) +        if local_key +          log "matches local key: " + local_key.filename +          log 'SSH MD5 fingerprint: ' + local_key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex) +          log 'SSH SHA256 fingerprint: ' + local_key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64) +        end +      end +    end +  end + +  # +  # update association between node and virtual machine. +  # +  # This might additionally be called by the 'leap node mv' command. +  # +  def do_vm_bind(global, options, args) +    node_name = args.first +    vm_id = args.last +    assert! node_name, "NODE_NAME is missing" +    assert! vm_id, "INSTANCE_ID is missing" +    node = manager.nodes[node_name] +    assert! node, "No node with name '#{node_name}'" +    bind_server_to_node(vm_id, node, options) +  end + +  #def do_vm_image_list(global, options, args) +  #  compute = fog_setup(nil, options) +  #  p compute.images.all +  #end + +  ## +  ## PRIVATE UTILITY METHODS +  ## + +  def stop_server(server, wait=false) +    if server.state == 'stopped' +      log :skipping, "virtual machine `#{server.id}` (already stopped)." +    elsif ['shutting-down', 'terminated'].include?(server.state) +      log :skipping, "virtual machine `#{server.id}` (being destroyed)." +    else +      log :stopping, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})" +      server.stop +      if wait +        log 'please wait...', :indent => 1 +        server.wait_for { state == 'stopped' } +        log 'done', :color => :green, :indent => 1 +      end +    end +  end + +  def start_server(server, wait=false) +    if server.state == 'running' +      log :skipping, "virtual machine `#{server.id}` (already running)." +    elsif ['shutting-down', 'terminated'].include?(server.state) +      log :skipping, "virtual machine `#{server.id}` (being destroyed)." +    else +      log :starting, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})" +      server.start +      if wait +        log 'please wait...', :indent => 1 +        server.wait_for { ready? } +        log 'done', :color => :green, :indent => 1 +      end +    end +  end + +  def destroy_server(server, wait=false) +    if ['shutting-down', 'terminated'].include?(server.state) +      log :skipping, "virtual machine `#{server.id}` (already being removed)." +    else +      log :terminated, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})" +      server.destroy +      if wait +        log 'please wait...', :indent => 1 +        server.wait_for { state == 'terminated' } +        log 'done', :color => :green, :indent => 1 +      end +    end +  end + +  # +  # for each server it finds, yields cloud, server +  # +  def servers_from_args(global, options, args) +    nodes = filter_vm_nodes(args) +    if nodes.any? +      nodes.each_node do |node| +        cloud  = new_cloud_handle(node, options) +        server = cloud.fetch_server_for_node(true) +        yield cloud, server +      end +    else +      instance_id = args.first +      cloud  = new_cloud_handle(nil, options) +      server = cloud.compute.servers.get(instance_id) +      if server.nil? +        bail! :error, "There is no virtual machine with ID `#{instance_id}`." +      end +      yield cloud, server +    end +  end + +  # +  # returns either: +  # +  # * the set of nodes specified by the filter, for this environment +  #   even if the result includes nodes that are not previously tagged with 'vm' +  # +  # * the list of all vm nodes for this environment, if filter is empty +  # +  def filter_vm_nodes(filter) +    if filter.nil? || filter.empty? +      return manager.filter(['vm'], :warning => false) +    elsif filter.is_a? Array +      return manager.filter(filter, :warning => false) +    else +      raise ArgumentError, 'could not understand filter' +    end +  end + +  def new_cloud_handle(node, options) +    require 'leap_cli/cloud' + +    config = manager.env.cloud +    name = nil +    if options[:mock] +      Fog.mock! +      name = 'mock_aws' +      config['mock_aws'] = { +        "api" => "aws", +        "vendor" => "aws", +        "auth" => { +          "aws_access_key_id" => "dummy", +          "aws_secret_access_key" => "dummy", +          "region" => "us-west-2" +        }, +        "instance_options" => { +          "image" => "dummy" +        } +      } +    elsif options[:auth] +      name = options[:auth] +      assert! config[name], "The value for --auth does not correspond to any value in cloud.json." +    elsif node && node['vm.auth'] +      name = node.vm.auth +      assert! config[name], "The node '#{node.name}' has a value for property 'vm.auth' that does not correspond to any value in cloud.json." +    elsif config.keys.length == 1 +      name = config.keys.first +      log :using, "cloud vendor credentials `#{name}`." +    else +      bail! "You must specify --mock, --auth, or a node filter." +    end + +    entry = config[name] # entry in cloud.json +    assert! entry, "cloud.json: could not find cloud resource `#{name}`." +    assert! entry['vendor'], "cloud.json: property `vendor` is missing from `#{name}` entry." +    assert! entry['api'], "cloud.json: property `api` is missing from `#{name}` entry. It must be one of #{config.possible_apis.join(', ')}." +    assert! entry['auth'], "cloud.json: property `auth` is missing from `#{name}` entry." +    assert! entry['auth']['region'], "cloud.json: property `auth.region` is missing from `#{name}` entry." +    assert! entry['api'] == 'aws', "cloud.json: currently, only 'aws' is supported for `api`." +    assert! entry['vendor'] == 'aws', "cloud.json: currently, only 'aws' is supported for `vendor`." + +    return LeapCli::Cloud.new(name, entry, node) +  end + +  def state_color(state) +    case state +      when 'running'; :green +      when 'terminated'; :red +      when 'stopped'; :magenta +      when 'shutting-down'; :yellow +      else; :white +    end +  end + +end; end | 
