summaryrefslogtreecommitdiff
path: root/lib/leap_cli/commands
diff options
context:
space:
mode:
authorMicah Anderson <micah@riseup.net>2016-11-04 10:54:28 -0400
committerMicah Anderson <micah@riseup.net>2016-11-04 10:54:28 -0400
commit34a381efa8f6295080c843f86bfa07d4e41056af (patch)
tree9282cf5d4c876688602705a7fa0002bc4a810bde /lib/leap_cli/commands
parent0a72bc6fd292bf9367b314fcb0347c4d35042f16 (diff)
parent5821964ff7e16ca7aa9141bd09a77d355db492a9 (diff)
Merge branch 'develop'
Diffstat (limited to 'lib/leap_cli/commands')
-rw-r--r--lib/leap_cli/commands/ca.rb649
-rw-r--r--lib/leap_cli/commands/compile.rb1
-rw-r--r--lib/leap_cli/commands/db.rb2
-rw-r--r--lib/leap_cli/commands/deploy.rb207
-rw-r--r--lib/leap_cli/commands/facts.rb13
-rw-r--r--lib/leap_cli/commands/info.rb15
-rw-r--r--lib/leap_cli/commands/inspect.rb46
-rw-r--r--lib/leap_cli/commands/list.rb93
-rw-r--r--lib/leap_cli/commands/node.rb177
-rw-r--r--lib/leap_cli/commands/node_init.rb104
-rw-r--r--lib/leap_cli/commands/open.rb103
-rw-r--r--lib/leap_cli/commands/run.rb50
-rw-r--r--lib/leap_cli/commands/ssh.rb17
-rw-r--r--lib/leap_cli/commands/test.rb41
-rw-r--r--lib/leap_cli/commands/user.rb130
-rw-r--r--lib/leap_cli/commands/util.rb50
-rw-r--r--lib/leap_cli/commands/vagrant.rb25
-rw-r--r--lib/leap_cli/commands/vm.rb467
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