diff options
author | Micah Anderson <micah@riseup.net> | 2016-11-04 10:54:28 -0400 |
---|---|---|
committer | Micah Anderson <micah@riseup.net> | 2016-11-04 10:54:28 -0400 |
commit | 34a381efa8f6295080c843f86bfa07d4e41056af (patch) | |
tree | 9282cf5d4c876688602705a7fa0002bc4a810bde /lib/leap_cli/commands | |
parent | 0a72bc6fd292bf9367b314fcb0347c4d35042f16 (diff) | |
parent | 5821964ff7e16ca7aa9141bd09a77d355db492a9 (diff) |
Merge branch 'develop'
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 |