summaryrefslogtreecommitdiff
path: root/lib/leap_cli
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
parent0a72bc6fd292bf9367b314fcb0347c4d35042f16 (diff)
parent5821964ff7e16ca7aa9141bd09a77d355db492a9 (diff)
Merge branch 'develop'
Diffstat (limited to 'lib/leap_cli')
-rw-r--r--lib/leap_cli/acme.rb101
-rw-r--r--lib/leap_cli/cloud.rb4
-rw-r--r--lib/leap_cli/cloud/cloud.rb380
-rw-r--r--lib/leap_cli/cloud/dependencies.rb40
-rw-r--r--lib/leap_cli/cloud/image.rb31
-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
-rw-r--r--lib/leap_cli/config/cloud.rb64
-rw-r--r--lib/leap_cli/config/environment.rb200
-rw-r--r--lib/leap_cli/config/filter.rb181
-rw-r--r--lib/leap_cli/config/manager.rb475
-rw-r--r--lib/leap_cli/config/node.rb245
-rw-r--r--lib/leap_cli/config/node_cert.rb124
-rw-r--r--lib/leap_cli/config/object.rb454
-rw-r--r--lib/leap_cli/config/object_list.rb215
-rw-r--r--lib/leap_cli/config/provider.rb22
-rw-r--r--lib/leap_cli/config/secrets.rb87
-rw-r--r--lib/leap_cli/config/sources.rb11
-rw-r--r--lib/leap_cli/config/tag.rb25
-rw-r--r--lib/leap_cli/leapfile_extensions.rb24
-rw-r--r--lib/leap_cli/load_libraries.rb23
-rw-r--r--lib/leap_cli/log_filter.rb176
-rw-r--r--lib/leap_cli/macros.rb16
-rw-r--r--lib/leap_cli/macros/files.rb27
-rw-r--r--lib/leap_cli/macros/haproxy.rb2
-rw-r--r--lib/leap_cli/macros/keys.rb10
-rw-r--r--lib/leap_cli/ssh.rb7
-rw-r--r--lib/leap_cli/ssh/backend.rb209
-rw-r--r--lib/leap_cli/ssh/formatter.rb70
-rw-r--r--lib/leap_cli/ssh/key.rb310
-rw-r--r--lib/leap_cli/ssh/options.rb100
-rw-r--r--lib/leap_cli/ssh/remote_command.rb124
-rw-r--r--lib/leap_cli/ssh/scripts.rb163
-rw-r--r--lib/leap_cli/util/console_table.rb62
-rw-r--r--lib/leap_cli/util/secret.rb55
-rw-r--r--lib/leap_cli/util/vagrant.rb26
-rw-r--r--lib/leap_cli/x509.rb16
-rw-r--r--lib/leap_cli/x509/certs.rb232
-rw-r--r--lib/leap_cli/x509/signing_profiles.rb104
-rw-r--r--lib/leap_cli/x509/utils.rb26
56 files changed, 5714 insertions, 917 deletions
diff --git a/lib/leap_cli/acme.rb b/lib/leap_cli/acme.rb
new file mode 100644
index 00000000..6c7dbe98
--- /dev/null
+++ b/lib/leap_cli/acme.rb
@@ -0,0 +1,101 @@
+require 'openssl'
+require 'acme-client'
+
+#
+# A little bit of sugar around gem acme-client
+#
+
+module LeapCli
+ class Acme
+
+ if ENV['ACME_STAGING']
+ ENDPOINT = 'https://acme-staging.api.letsencrypt.org/'
+ puts "using endpoint " + ENDPOINT
+ else
+ ENDPOINT = 'https://acme-v01.api.letsencrypt.org/'
+ end
+
+ def initialize(domain: nil, key:)
+ @client = ::Acme::Client.new(
+ private_key: key,
+ endpoint: ENDPOINT,
+ connection_options: {request: {open_timeout: 5, timeout: 5}}
+ )
+ @domain = domain
+ end
+
+ #
+ # static methods
+ #
+
+ def self.new_private_key
+ return OpenSSL::PKey::RSA.new(4096)
+ end
+
+ def self.load_private_key(pem_encoded_key)
+ return OpenSSL::PKey::RSA.new(pem_encoded_key)
+ end
+
+ def self.load_csr(pem_encoded_csr)
+ return OpenSSL::X509::Request.new(pem_encoded_csr)
+ end
+
+ #
+ # instance methods
+ #
+
+ #
+ # register a new account key with CA
+ #
+ def register(contact)
+ registration = @client.register(contact: 'mailto:' + contact)
+ if registration && registration.agree_terms
+ return registration
+ else
+ return false
+ end
+ end
+
+ #
+ # authorize account key for domain
+ #
+ def authorize
+ authorization = @client.authorize(domain: @domain)
+ challenge = nil
+ begin
+ while true
+ if authorization.status == 'pending'
+ challenge = authorization.http01
+ yield challenge
+ challenge.request_verification
+ sleep 1
+ authorization.verify_status
+ if challenge.error
+ return 'error', challenge.error
+ end
+ elsif authorization.status == 'invalid'
+ challenge_msg = (challenge.nil? ? '' : challenge.error)
+ return 'error', 'Something bad happened. %s' % challenge_msg
+ elsif authorization.status == 'valid'
+ return 'valid', nil
+ else
+ challenge_msg = (challenge.nil? ? '' : challenge.error)
+ return 'error', 'status: %s, response message: %s' % [authorization.status, challenge_msg]
+ end
+ end
+ rescue Interrupt
+ return 'error', 'interrupted'
+ end
+ rescue ::Acme::Client::Error::Unauthorized => exc
+ return 'unauthorized', exc.to_s
+ end
+
+ #
+ # get new certificate
+ #
+ def get_certificate(csr)
+ return @client.new_certificate(csr)
+ end
+
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/cloud.rb b/lib/leap_cli/cloud.rb
new file mode 100644
index 00000000..268cea38
--- /dev/null
+++ b/lib/leap_cli/cloud.rb
@@ -0,0 +1,4 @@
+
+require 'fog/aws'
+require_relative 'cloud/cloud.rb'
+require_relative 'cloud/image.rb'
diff --git a/lib/leap_cli/cloud/cloud.rb b/lib/leap_cli/cloud/cloud.rb
new file mode 100644
index 00000000..2c06e7ed
--- /dev/null
+++ b/lib/leap_cli/cloud/cloud.rb
@@ -0,0 +1,380 @@
+#
+# An abstractions in front of Fog, which is an abstraction in front of
+# the AWS api. Oh my!
+#
+# A Cloud object binds a particular node with particular Fog
+# authentication credentials.
+#
+# NOTE: Possible AWS options for creating instances:
+#
+# options = {
+# 'BlockDeviceMapping' => block_device_mapping,
+# 'NetworkInterfaces' => network_interfaces,
+# 'ClientToken' => client_token,
+# 'DisableApiTermination' => disable_api_termination,
+# 'EbsOptimized' => ebs_optimized,
+# 'IamInstanceProfile.Arn' => @iam_instance_profile_arn,
+# 'IamInstanceProfile.Name' => @iam_instance_profile_name,
+# 'InstanceInitiatedShutdownBehavior' => instance_initiated_shutdown_behavior,
+# 'InstanceType' => flavor_id,
+# 'KernelId' => kernel_id,
+# 'KeyName' => key_name,
+# 'Monitoring.Enabled' => monitoring,
+# 'Placement.AvailabilityZone' => availability_zone,
+# 'Placement.GroupName' => placement_group,
+# 'Placement.Tenancy' => tenancy,
+# 'PrivateIpAddress' => private_ip_address,
+# 'RamdiskId' => ramdisk_id,
+# 'SecurityGroup' => groups,
+# 'SecurityGroupId' => security_group_ids,
+# 'SubnetId' => subnet_id,
+# 'UserData' => user_data,
+# }
+#
+
+module LeapCli
+ class Cloud
+ DEFAULT_INSTANCE_OPTIONS = {
+ "InstanceType" => "t2.nano"
+ }
+ LEAP_SG_NAME = 'leap_default'
+ LEAP_SG_DESC = 'Default security group for LEAP nodes'
+
+ include LeapCli::LogCommand
+
+ attr_reader :compute # Fog::Compute object
+ attr_reader :node # Config::Node, if any
+ attr_reader :options # options for the VMs, if any
+ attr_reader :image # which vm image to use, if any
+ attr_reader :name # name of which entry in cloud.json to use
+
+ def initialize(name, conf, node=nil)
+ @node = node
+ @name = name
+ @conf = conf
+ @compute = nil
+ @options = DEFAULT_INSTANCE_OPTIONS
+ @image = nil
+
+ raise ArgumentError, 'name missing' unless @name
+ raise ArgumentError, 'config missing' unless @conf
+ raise ArgumentError, 'config auth missing' unless @conf["auth"]
+ raise ArgumentError, 'config auth missing' unless @conf["vendor"]
+
+ credentials = @conf["auth"].symbolize_keys
+ credentials[:provider] = @conf["vendor"]
+ @compute = Fog::Compute.new(credentials)
+
+ if @conf['default_options']
+ @options = @options.merge(@conf['default_options'])
+ end
+ @image = @conf['default_image'] || Cloud.aws_image(credentials[:region])
+ if @node
+ @options = @options.merge(node.vm.options) if node['vm.options']
+ @image = node.vm.image if node['vm.image']
+ end
+
+ unless @options['InstanceType']
+ raise ArgumentError, 'VM instance type is required. See https://leap.se/virtual-machines for more information.'
+ end
+ unless @image
+ raise ArgumentError, 'VM image is required. See https://leap.se/virtual-machines for more information.'
+ end
+ end
+
+ #
+ # fetches or creates a server for this cloud object.
+ #
+ def fetch_or_create_server(options)
+ fetch_server_for_node || create_new_vm_instance(choose_ssh_key: options[:choose_ssh_key])
+ end
+
+ #
+ # fetches the server for a particular node.
+ #
+ # return nil if this cloud object has no node, or there is no corresponding
+ # server.
+ #
+ def fetch_server_for_node(bail_on_failure=false)
+ server = nil
+ return nil unless @node
+
+ # does an instance exist that matches the node's vm.id?
+ if @node.vm_id?
+ instance_id = @node.vm.id
+ server = @compute.servers.get(instance_id)
+ end
+
+ # does an instance exist that is tagged with this node name?
+ if server.nil?
+ response = @compute.describe_instances({"tag:node_name" => @node.name})
+ # puts JSON.pretty_generate(response.body)
+ if !response.body["reservationSet"].empty?
+ instances = response.body["reservationSet"].first["instancesSet"]
+ if instances.size > 1
+ bail! "There are multiple VMs with the same node name tag! Manually remove one before continuing."
+ elsif instances.size == 1
+ instance_id = instances.first["instanceId"]
+ server = @compute.servers.get(instance_id)
+ end
+ end
+ end
+
+ if server.nil? && bail_on_failure
+ bail! :error, "A virtual machine could not be found for node `#{@node.name}`. Things to try:" do
+ log "check the output of `leap vm status`"
+ log "check the value of `vm.id` in #{@node.name}.json"
+ log "run `leap vm add #{@node.name}` to create a corresponding virtual machine"
+ end
+ end
+
+ return server
+ end
+
+ #
+ # associates a node with a vm
+ #
+ def bind_server_to_node(server)
+ unless @node
+ raise ArgumentError, 'no node'
+ end
+ if server.public_ip_address.nil?
+ bail! do
+ log 'The virtual machine `%s` must have an IP address in order to bind it to the configuration `%s`.' % [
+ server.id, Path.relative_path(Path.named_path([:node_config, @node.name]))]
+ log 'To fix, run `leap vm start %s`' % server.id
+ end
+ end
+
+ # assign tag
+ @compute.create_tags(server.id, {'node_name' => @node.name})
+ log :created, "association between node '%s' and vm '%s'" % [@node.name, server.id]
+
+ # update node json
+ @node.update_json({
+ "ip_address" => server.public_ip_address,
+ "vm"=> {"id"=>server.id}
+ })
+ end
+
+ #
+ # disassociates a node from a vm
+ #
+ def unbind_server_from_node(server)
+ raise ArgumentError, 'no node' unless @node
+
+ # assign tag
+ @compute.delete_tags(server.id, {'node_name' => @node.name})
+ log :removed, "association between node '%s' and vm '%s'" % [@node.name, server.id]
+
+ # update node json
+ @node.update_json({
+ "ip_address" => '0.0.0.0',
+ "vm"=> {"id" => ""}
+ })
+ end
+
+ #
+ # return an AWS KeyPair object, potentially uploading it to the server
+ # if necessary.
+ #
+ # this is used when initially creating the vm. After the first `node init`, then
+ # all sysadmins should have access to the server.
+ #
+ # NOTE: ssh and aws use different types of fingerprint
+ #
+ def find_or_create_key_pair(pick_ssh_key_method)
+ require 'leap_cli/ssh'
+ key_pair, local_key = match_ssh_key(:user_only => true)
+ if key_pair
+ log :using, "user SSH key #{local_key.filename}" do
+ log 'AWS MD5 fingerprint: ' + local_key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex)
+ 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
+ elsif key_pair.nil?
+ username, key = pick_ssh_key_method.call(self)
+ key_pair = upload_ssh_key(username, key)
+ end
+ return key_pair
+ end
+
+ #
+ # checks if there is a match between a local key and a registered key_pair
+ #
+ # options:
+ # :key_pair -- limit comparisons to this key_pair object.
+ # :user_only -- limit comparisons to the user's ~/.ssh directory only
+ #
+ # returns:
+ #
+ # key_pair -- an AWS KeyPair
+ # local_key -- a LeapCLi::SSH::Key
+ #
+ def match_ssh_key(options={})
+ key_pair = options[:key_pair]
+ local_keys_to_check = LeapCli::SSH::Key.my_public_keys
+ unless options[:user_only]
+ local_keys_to_check += LeapCli::SSH::Key.provider_public_keys
+ end
+ fingerprints = Hash[local_keys_to_check.map {|k|
+ [k.fingerprint(:digest => :md5, :type => :der, :encoding => :hex), k]
+ }]
+ key_pair ||= @compute.key_pairs.select {|key_pair|
+ fingerprints.include?(key_pair.fingerprint)
+ }.first
+ if key_pair
+ local_key = fingerprints[key_pair.fingerprint]
+ return key_pair, local_key
+ else
+ return nil, nil
+ end
+ end
+
+ def wait_for_ssh_host_key(server)
+ require 'leap_cli/ssh'
+ return nil if Fog.mock?
+ tries = 0
+ host_key = nil
+ cloud = self
+ server.wait_for {
+ if tries > 0
+ LeapCli.log :waiting, "for SSH host key..."
+ elsif tries > 20
+ return nil
+ end
+ tries += 1
+ ssh_host_keys = cloud.ssh_host_keys(server)
+ if ssh_host_keys.nil?
+ false
+ else
+ host_key = SSH::Key.pick_best_key(ssh_host_keys)
+ true
+ end
+ }
+ return host_key
+ end
+
+ #
+ # checks the console of the server for the ssh host keys
+ #
+ # returns nil if they cannot be found.
+ #
+ def ssh_host_keys(server)
+ require 'leap_cli/ssh'
+ return nil if Fog.mock?
+ response = @compute.get_console_output(server.id)
+ output = response.body["output"]
+ if output.nil?
+ return nil
+ end
+ keys = output.match(
+ /-----BEGIN SSH HOST KEY KEYS-----(.*)-----END SSH HOST KEY KEYS-----/m
+ )
+ if keys.nil?
+ return nil
+ else
+ ssh_key_list = keys[1].strip.split("\r\n").map {|key_str|
+ SSH::Key.load(key_str)
+ }
+ return ssh_key_list.compact
+ end
+ end
+
+ private
+
+ #
+ # Every AWS instance requires a security group, which is just a simple firewall.
+ # In the future, we could create a separate security group for each node,
+ # and set the rules to match what the rules should be for that node.
+ #
+ # However, for now, we just use a security group 'leap_default' that opens
+ # all the ports.
+ #
+ # The default behavior for AWS security groups is:
+ # all ingress traffic is blocked and all egress traffic is allowed.
+ #
+ def find_or_create_security_group
+ group = @compute.security_groups.get(LEAP_SG_NAME)
+ if group.nil?
+ group = @compute.security_groups.create(
+ :name => LEAP_SG_NAME,
+ :description => LEAP_SG_DESC
+ )
+ group.authorize_port_range(0..65535,
+ :ip_protocol => 'tcp',
+ :cidr_ip => '0.0.0.0/0',
+ )
+ group.authorize_port_range(0..65535,
+ :ip_protocol => 'udp',
+ :cidr_ip => '0.0.0.0/0',
+ )
+ end
+ return group
+ end
+
+ #
+ # key - a LeapCli::SSH::Key object
+ # returns -- AWS KeyPair
+ #
+ def upload_ssh_key(username, key)
+ key_name = 'leap_' + username
+ key_pair = @compute.key_pairs.create(
+ :name => key_name,
+ :public_key => key.public_key.to_s
+ )
+ log :registered, "public key" do
+ log 'cloud provider: ' + @name
+ log 'name: ' + key_name
+ log 'AWS MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex)
+ log 'SSH MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex)
+ log 'SSH SHA256 fingerprint: ' + key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64)
+ end
+ return key_pair
+ end
+
+ def create_new_vm_instance(choose_ssh_key: nil)
+ log :creating, "new vm instance..."
+ assert! @image, "No image found. Specify `default_image` in cloud.json or `vm.image` in node's config."
+ if Fog.mock?
+ options = @options
+ else
+ key_pair = find_or_create_key_pair(choose_ssh_key)
+ security_group = find_or_create_security_group
+ options = @options.merge({
+ 'KeyName' => key_pair.name,
+ 'SecurityGroup' => security_group.name
+ })
+ end
+ response = @compute.run_instances(
+ @image,
+ 1, # min count
+ 1, # max count
+ options
+ )
+ instance_id = response.body["instancesSet"].first["instanceId"]
+ log :created, "vm with instance id #{instance_id}."
+ server = @compute.servers.get(instance_id)
+ if server.nil?
+ bail! :error, "could not query instance '#{instance_id}'."
+ end
+ unless Fog.mock?
+ tries = 0
+ server.wait_for {
+ if tries > 0
+ LeapCli.log :waiting, "for IP address to be assigned..."
+ end
+ tries += 1
+ !public_ip_address.nil?
+ }
+ if options[:wait]
+ log :waiting, "for vm #{instance_id} to start..."
+ server.wait_for { ready? }
+ log :started, "#{instance_id} with #{server.public_ip_address}"
+ end
+ end
+ return server
+ end
+
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/cloud/dependencies.rb b/lib/leap_cli/cloud/dependencies.rb
new file mode 100644
index 00000000..fd690e59
--- /dev/null
+++ b/lib/leap_cli/cloud/dependencies.rb
@@ -0,0 +1,40 @@
+#
+# I am not sure this is a good idea, but it might be. Tricky, so disabled for now
+#
+
+=begin
+module LeapCli
+ class Cloud
+
+ def self.check_required_gems
+ begin
+ require "fog"
+ rescue LoadError
+ bail! do
+ log :error, "The 'vm' command requires the gem 'fog-core'. Please run `gem install fog-core` and try again."
+ end
+ end
+
+ fog_gems = @cloud.required_gems
+ if !options[:mock] && fog_gems.empty?
+ bail! do
+ log :warning, "no vm providers are configured in cloud.json."
+ log "You must have credentials for one of: #{@cloud.possible_apis.join(', ')}."
+ end
+ end
+
+ fog_gems.each do |name, gem_name|
+ begin
+ require gem_name.sub('-','/')
+ rescue LoadError
+ bail! do
+ log :error, "The 'vm' command requires the gem '#{gem_name}' (because of what is configured in cloud.json)."
+ log "Please run `sudo gem install #{gem_name}` and try again."
+ end
+ end
+ end
+ end
+
+ end
+end
+=end \ No newline at end of file
diff --git a/lib/leap_cli/cloud/image.rb b/lib/leap_cli/cloud/image.rb
new file mode 100644
index 00000000..1f7f47b9
--- /dev/null
+++ b/lib/leap_cli/cloud/image.rb
@@ -0,0 +1,31 @@
+module LeapCli
+ class Cloud
+
+ #
+ # returns the latest official debian image for
+ # a particular AWS region
+ #
+ # https://wiki.debian.org/Cloud/AmazonEC2Image/Jessie
+ # current list based on Debian 8.4
+ #
+ # might return nil if no image is found.
+ #
+ def self.aws_image(region)
+ image_list = %q[
+ ap-northeast-1 ami-d7d4c5b9
+ ap-northeast-2 ami-9a03caf4
+ ap-southeast-1 ami-73974210
+ ap-southeast-2 ami-09daf96a
+ eu-central-1 ami-ccc021a3
+ eu-west-1 ami-e079f893
+ sa-east-1 ami-d3ae21bf
+ us-east-1 ami-c8bda8a2
+ us-west-1 ami-45374b25
+ us-west-2 ami-98e114f8
+ ]
+ region_to_image = Hash[image_list.strip.split("\n").map{|i| i.split(" ")}]
+ return region_to_image[region]
+ end
+
+ end
+end \ No newline at end of file
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
diff --git a/lib/leap_cli/config/cloud.rb b/lib/leap_cli/config/cloud.rb
new file mode 100644
index 00000000..e3e5c1f1
--- /dev/null
+++ b/lib/leap_cli/config/cloud.rb
@@ -0,0 +1,64 @@
+# encoding: utf-8
+#
+# A class for the cloud.json file
+#
+# Example format:
+#
+# {
+# "my_aws": {
+# "api": "aws",
+# "vendor": "aws",
+# "auth": {
+# "region": "us-west-2",
+# "aws_access_key_id": "xxxxxxxxxxxxxxx",
+# "aws_secret_access_key": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
+# },
+# "default_image": "ami-98e114f8",
+# "default_options": {
+# "InstanceType": "t2.nano"
+# }
+# }
+# }
+#
+
+module LeapCli; module Config
+
+ # http://fog.io/about/supported_services.html
+ VM_APIS = {
+ 'aws' => 'fog-aws',
+ 'google' => 'fog-google',
+ 'libvirt' => 'fog-libvirt',
+ 'openstack' => 'fog-openstack',
+ 'rackspace' => 'fog-rackspace'
+ }
+
+ class Cloud < Hash
+ def initialize(env=nil)
+ end
+
+ #
+ # returns hash, each key is the name of an API that is
+ # needed and the value is the name of the gem.
+ #
+ # only provider APIs that are required because they are present
+ # in cloud.json are included.
+ #
+ def required_gems
+ required = {}
+ self.each do |name, conf|
+ api = conf["api"]
+ required_gems[api] = VM_APIS[api]
+ end
+ return required
+ end
+
+ #
+ # returns an array of all possible providers
+ #
+ def possible_apis
+ VM_APIS.keys
+ end
+
+ end
+
+end; end
diff --git a/lib/leap_cli/config/environment.rb b/lib/leap_cli/config/environment.rb
new file mode 100644
index 00000000..ce570839
--- /dev/null
+++ b/lib/leap_cli/config/environment.rb
@@ -0,0 +1,200 @@
+#
+# All configurations files can be isolated into separate environments.
+#
+# Each config json in each environment inherits from the default environment,
+# which in term inherits from the "_base_" environment:
+#
+# _base_ -- base provider in leap_platform
+# '- default -- environment in provider dir when no env is set
+# '- production -- example environment
+#
+
+module LeapCli; module Config
+
+ class Environment
+ # the String name of the environment
+ attr_accessor :name
+
+ # the shared Manager object
+ attr_accessor :manager
+
+ # hashes of {name => Config::Object}
+ attr_accessor :services, :tags, :partials
+
+ # a Config::Provider
+ attr_accessor :provider
+
+ # a Config::Object
+ attr_accessor :common
+
+ # shared, non-inheritable
+ def nodes; @@nodes; end
+ def secrets; @@secrets; end
+ def cloud; @@cloud; end
+
+ def initialize(manager, name, search_dir, parent, options={})
+ @@nodes ||= nil
+ @@secrets ||= nil
+ @@cloud ||= nil
+
+ @manager = manager
+ @name = name
+
+ load_provider_files(search_dir, options)
+
+ if parent
+ @services.inherit_from! parent.services, self
+ @tags.inherit_from! parent.tags , self
+ @partials.inherit_from! parent.partials, self
+ @common.inherit_from! parent.common
+ @provider.inherit_from! parent.provider
+ end
+
+ if @provider
+ @provider.set_env(name)
+ @provider.validate!
+ end
+ end
+
+ def load_provider_files(search_dir, options)
+ #
+ # load empty environment if search_dir doesn't exist
+ #
+ if search_dir.nil? || !Dir.exist?(search_dir)
+ @services = Config::ObjectList.new
+ @tags = Config::ObjectList.new
+ @partials = Config::ObjectList.new
+ @provider = Config::Provider.new
+ @common = Config::Object.new
+ @cloud = Config::Cloud.new
+ return
+ end
+
+ #
+ # inheritable
+ #
+ if options[:scope]
+ scope = options[:scope]
+ @services = load_all_json(Path.named_path([:service_env_config, '*', scope], search_dir), Config::Tag, options)
+ @tags = load_all_json(Path.named_path([:tag_env_config, '*', scope], search_dir), Config::Tag, options)
+ @partials = load_all_json(Path.named_path([:service_env_config, '_*', scope], search_dir), Config::Tag, options)
+ @provider = load_json( Path.named_path([:provider_env_config, scope], search_dir), Config::Provider, options)
+ @common = load_json( Path.named_path([:common_env_config, scope], search_dir), Config::Object, options)
+ else
+ @services = load_all_json(Path.named_path([:service_config, '*'], search_dir), Config::Tag, options)
+ @tags = load_all_json(Path.named_path([:tag_config, '*'], search_dir), Config::Tag, options)
+ @partials = load_all_json(Path.named_path([:service_config, '_*'], search_dir), Config::Tag, options)
+ @provider = load_json( Path.named_path(:provider_config, search_dir), Config::Provider, options)
+ @common = load_json( Path.named_path(:common_config, search_dir), Config::Object, options)
+ end
+
+ # remove 'name' from partials, since partials get merged with nodes
+ @partials.values.each {|partial| partial.delete('name'); }
+
+ #
+ # shared
+ #
+ # shared configs are also non-inheritable
+ # load the first ones we find, and only those.
+ #
+ if @@nodes.nil? || @@nodes.empty?
+ @@nodes = load_all_json(Path.named_path([:node_config, '*'], search_dir), Config::Node, options)
+ end
+ if @@secrets.nil? || @@secrets.empty?
+ @@secrets = load_json(Path.named_path(:secrets_config, search_dir), Config::Secrets, options)
+ end
+ if @@cloud.nil? || @@cloud.empty?
+ @@cloud = load_json(Path.named_path(:cloud_config, search_dir), Config::Cloud)
+ end
+ end
+
+ #
+ # Loads a json template file as a Hash (used only when creating a new node .json
+ # file for the first time).
+ #
+ def template(template)
+ path = Path.named_path([:template_config, template], Path.provider_base)
+ if File.exist?(path)
+ return load_json(path, Config::Object)
+ else
+ return nil
+ end
+ end
+
+ #
+ # Alters the node's json config file. Unfortunately, doing this will
+ # strip out all the comments.
+ #
+ def update_node_json(node, new_values)
+ node_json_path = Path.named_path([:node_config, node.name])
+ old_data = load_json(node_json_path, Config::Node)
+ new_data = old_data.merge(new_values)
+ new_contents = JSON.sorted_generate(new_data) + "\n"
+ Util::write_file! node_json_path, new_contents
+ end
+
+ private
+
+ def load_all_json(pattern, object_class, options={})
+ results = Config::ObjectList.new
+ Dir.glob(pattern).each do |filename|
+ next if options[:no_dots] && File.basename(filename) !~ /^[^\.]*\.json$/
+ obj = load_json(filename, object_class)
+ if obj
+ name = File.basename(filename).force_encoding('utf-8').sub(/^([^\.]+).*\.json$/,'\1')
+ obj['name'] ||= name
+ if options[:env]
+ obj.environment = options[:env]
+ end
+ results[name] = obj
+ end
+ end
+ results
+ end
+
+ def load_json(filename, object_class, options={})
+ if !File.exist?(filename)
+ return object_class.new(self)
+ end
+
+ Util::log :loading, filename, 3
+
+ #
+ # Read a JSON file, strip out comments.
+ #
+ # UTF8 is the default encoding for JSON, but others are allowed:
+ # https://www.ietf.org/rfc/rfc4627.txt
+ #
+ buffer = StringIO.new
+ File.open(filename, "rb", :encoding => 'UTF-8') do |f|
+ while (line = f.gets)
+ next if line =~ /^\s*\/\//
+ buffer << line
+ end
+ end
+
+ #
+ # force UTF-8
+ #
+ if $ruby_version >= [1,9]
+ string = buffer.string.force_encoding('utf-8')
+ else
+ string = Iconv.conv("UTF-8//IGNORE", "UTF-8", buffer.string)
+ end
+
+ # parse json
+ begin
+ hash = JSON.parse(string, :object_class => Hash, :array_class => Array) || {}
+ rescue SyntaxError, JSON::ParserError => exc
+ Util::log 0, :error, 'in file "%s":' % filename
+ Util::log 0, exc.to_s, :indent => 1
+ return nil
+ end
+ object = object_class.new(self)
+ object.deep_merge!(hash)
+ return object
+ end
+
+ end # end Environment
+
+end; end \ No newline at end of file
diff --git a/lib/leap_cli/config/filter.rb b/lib/leap_cli/config/filter.rb
new file mode 100644
index 00000000..07424894
--- /dev/null
+++ b/lib/leap_cli/config/filter.rb
@@ -0,0 +1,181 @@
+#
+# Many leap_cli commands accept a list of filters to select a subset of nodes for the command to
+# be applied to. This class is a helper for manager to run these filters.
+#
+# Classes other than Manager should not use this class.
+#
+# Filter rules:
+#
+# * A filter consists of a list of tokens
+# * A token may be a service name, tag name, environment name, or node name.
+# * Each token may be optionally prefixed with a plus sign.
+# * Multiple tokens with a plus are treated as an OR condition,
+# but treated as an AND condition with the plus sign.
+#
+# For example
+#
+# * openvpn +development => all nodes with service 'openvpn' AND environment 'development'
+# * openvpn seattle => all nodes with service 'openvpn' OR tag 'seattle'.
+#
+# There can only be one environment specified. Typically, there are also tags
+# for each environment name. These name are treated as environments, not tags.
+#
+module LeapCli
+ module Config
+ class Filter
+
+ #
+ # filter -- array of strings, each one a filter
+ # options -- hash, possible keys include
+ # :nopin -- disregard environment pinning
+ # :local -- if false, disallow local nodes
+ # :warning -- if false, don't print a warning when no nodes are found.
+ #
+ # A nil value in the filters array indicates
+ # the default environment. This is in order to support
+ # calls like `manager.filter(environments)`
+ #
+ def initialize(filters, options, manager)
+ @filters = filters.nil? ? [] : filters.dup
+ @environments = []
+ @options = options
+ @manager = manager
+
+ # split filters by pulling out items that happen
+ # to be environment names.
+ if LeapCli.leapfile.environment.nil? || @options[:nopin]
+ @environments = []
+ else
+ @environments = [LeapCli.leapfile.environment]
+ end
+ @filters.select! do |filter|
+ if filter.nil?
+ @environments << nil unless @environments.include?(nil)
+ false
+ else
+ filter_text = filter.sub(/^\+/,'')
+ if is_environment?(filter_text)
+ if filter_text == LeapCli.leapfile.environment
+ # silently ignore already pinned environments
+ elsif (filter =~ /^\+/ || @filters.first == filter) && !@environments.empty?
+ LeapCli::Util.bail! do
+ LeapCli.log "Environments are exclusive: no node is in two environments." do
+ LeapCli.log "Tried to filter on '#{@environments.join('\' AND \'')}' AND '#{filter_text}'"
+ end
+ end
+ else
+ @environments << filter_text
+ end
+ false
+ else
+ true
+ end
+ end
+ end
+
+ # don't let the first filter have a + prefix
+ if @filters[0] =~ /^\+/
+ @filters[0] = @filters[0][1..-1]
+ end
+ end
+
+ # actually run the filter, returns a filtered list of nodes
+ def nodes()
+ if @filters.empty?
+ return nodes_for_empty_filter
+ else
+ return nodes_for_filter
+ end
+ end
+
+ private
+
+ def nodes_for_empty_filter
+ node_list = @manager.nodes
+ if @environments.any?
+ node_list = node_list[ @environments.collect{|e|[:environment, env_to_filter(e)]} ]
+ end
+ if @options[:local] === false
+ node_list = node_list[:environment => '!local']
+ end
+ if @options[:disabled] === false
+ node_list = node_list[:environment => '!disabled']
+ end
+ node_list
+ end
+
+ def nodes_for_filter
+ node_list = Config::ObjectList.new
+ @filters.each do |filter|
+ if filter =~ /^\+/
+ keep_list = nodes_for_name(filter[1..-1])
+ node_list.delete_if do |name, node|
+ if keep_list[name]
+ false
+ else
+ true
+ end
+ end
+ else
+ node_list.merge!(nodes_for_name(filter))
+ end
+ end
+ node_list
+ end
+
+ private
+
+ #
+ # returns a set of nodes corresponding to a single name,
+ # where name could be a node name, service name, or tag name.
+ #
+ # For services and tags, we only include nodes for the
+ # environments that are active
+ #
+ def nodes_for_name(name)
+ if node = @manager.nodes[name]
+ return Config::ObjectList.new(node)
+ elsif @environments.empty?
+ if @manager.services[name]
+ return @manager.env('_all_').services[name].node_list
+ elsif @manager.tags[name]
+ return @manager.env('_all_').tags[name].node_list
+ elsif @options[:warning] != false
+ LeapCli.log :warning, "filter '#{name}' does not match any node names, tags, services, or environments."
+ return Config::ObjectList.new
+ else
+ return Config::ObjectList.new
+ end
+ else
+ node_list = Config::ObjectList.new
+ if @manager.services[name]
+ @environments.each do |env|
+ node_list.merge!(@manager.env(env).services[name].node_list)
+ end
+ elsif @manager.tags[name]
+ @environments.each do |env|
+ node_list.merge!(@manager.env(env).tags[name].node_list)
+ end
+ elsif @options[:warning] != false
+ LeapCli.log :warning, "filter '#{name}' does not match any node names, tags, services, or environments."
+ end
+ return node_list
+ end
+ end
+
+ #
+ # when pinning, we use the name 'default' to specify nodes
+ # without an environment set, but when filtering, we need to filter
+ # on :environment => nil.
+ #
+ def env_to_filter(environment)
+ environment == 'default' ? nil : environment
+ end
+
+ def is_environment?(text)
+ text == 'default' || @manager.environment_names.include?(text)
+ end
+
+ end
+ end
+end
diff --git a/lib/leap_cli/config/manager.rb b/lib/leap_cli/config/manager.rb
new file mode 100644
index 00000000..d69a5808
--- /dev/null
+++ b/lib/leap_cli/config/manager.rb
@@ -0,0 +1,475 @@
+# encoding: utf-8
+
+require 'json/pure'
+
+if $ruby_version < [1,9]
+ require 'iconv'
+end
+
+module LeapCli
+ module Config
+
+ #
+ # A class to manage all the objects in all the configuration files.
+ #
+ class Manager
+
+ def initialize
+ @environments = {} # hash of `Environment` objects, keyed by name.
+ Config::Object.send(:include, LeapCli::Macro)
+ end
+
+ ##
+ ## ATTRIBUTES
+ ##
+
+ #
+ # returns the Hash of the contents of facts.json
+ #
+ def facts
+ @facts ||= begin
+ content = Util.read_file(:facts)
+ if !content || content.empty?
+ content = "{}"
+ end
+ JSON.parse(content)
+ rescue SyntaxError, JSON::ParserError => exc
+ Util::bail! "Could not parse facts.json -- #{exc}"
+ end
+ end
+
+ #
+ # returns an Array of all the environments defined for this provider.
+ # the returned array includes nil (for the default environment)
+ #
+ def environment_names
+ @environment_names ||= begin
+ [nil] + (env.tags.field('environment') + env.nodes.field('environment')).compact.uniq
+ end
+ end
+
+ #
+ # Returns the appropriate environment variable
+ #
+ def env(env=nil)
+ @environments[env || 'default']
+ end
+
+ #
+ # The default accessors
+ #
+ # For these defaults, use 'default' environment, or whatever
+ # environment is pinned.
+ #
+ # I think it might be an error that these are ever used
+ # and I would like to get rid of them.
+ #
+ def services; env(default_environment).services; end
+ def tags; env(default_environment).tags; end
+ def partials; env(default_environment).partials; end
+ def provider; env(default_environment).provider; end
+ def common; env(default_environment).common; end
+ def secrets; env(default_environment).secrets; end
+ def nodes; env(default_environment).nodes; end
+ def template(*args)
+ self.env.template(*args)
+ end
+
+ def default_environment
+ LeapCli.leapfile.environment
+ end
+
+ ##
+ ## IMPORT EXPORT
+ ##
+
+ def add_environment(args)
+ if args[:inherit]
+ parent = @environments[args.delete(:inherit)]
+ else
+ parent = nil
+ end
+ env = Environment.new(
+ self,
+ args.delete(:name),
+ args.delete(:dir),
+ parent,
+ args
+ )
+ @environments[env.name] = env
+ end
+
+ #
+ # load .json configuration files
+ #
+ def load(options = {})
+ # load base
+ add_environment(name: '_base_', dir: Path.provider_base)
+
+ # load provider
+ Util::assert_files_exist!(Path.named_path(:provider_config, Path.provider))
+ add_environment(name: 'default', dir: Path.provider,
+ inherit: '_base_', no_dots: true)
+
+ # create a special '_all_' environment, used for tracking
+ # the union of all the environments
+ add_environment(name: '_all_', inherit: 'default')
+
+ # load environments
+ environment_names.each do |ename|
+ if ename
+ LeapCli.log 3, :loading, '%s environment...' % ename
+ add_environment(name: ename, dir: Path.provider,
+ inherit: 'default', scope: ename)
+ end
+ end
+
+ # apply inheritance
+ env.nodes.each do |name, node|
+ Util::assert! name =~ /^[0-9a-z-]+$/, "Illegal character(s) used in node name '#{name}'"
+ env.nodes[name] = apply_inheritance(node)
+ end
+
+ # do some node-list post-processing
+ cleanup_node_lists(options)
+
+ # apply service.rb, common.rb, and provider.rb control files
+ apply_control_files
+ end
+
+ #
+ # save compiled hiera .yaml files
+ #
+ # if a node_list is specified, only update those .yaml files.
+ # otherwise, update all files, destroying files that are no longer used.
+ #
+ def export_nodes(node_list=nil)
+ updated_hiera = []
+ updated_files = []
+ existing_hiera = nil
+ existing_files = nil
+
+ unless node_list
+ node_list = env.nodes
+ existing_hiera = Dir.glob(Path.named_path([:hiera, '*'], Path.provider))
+ existing_files = Dir.glob(Path.named_path([:node_files_dir, '*'], Path.provider))
+ end
+
+ node_list.each_node do |node|
+ filepath = Path.named_path([:node_files_dir, node.name], Path.provider)
+ hierapath = Path.named_path([:hiera, node.name], Path.provider)
+ Util::write_file!(hierapath, node.dump_yaml)
+ updated_files << filepath
+ updated_hiera << hierapath
+ end
+
+ if @disabled_nodes
+ # make disabled nodes appear as if they are still active
+ @disabled_nodes.each_node do |node|
+ updated_files << Path.named_path([:node_files_dir, node.name], Path.provider)
+ updated_hiera << Path.named_path([:hiera, node.name], Path.provider)
+ end
+ end
+
+ # remove files that are no longer needed
+ if existing_hiera
+ (existing_hiera - updated_hiera).each do |filepath|
+ Util::remove_file!(filepath)
+ end
+ end
+ if existing_files
+ (existing_files - updated_files).each do |filepath|
+ Util::remove_directory!(filepath)
+ end
+ end
+ end
+
+ def export_secrets(clean_unused_secrets = false)
+ if env.secrets.any?
+ Util.write_file!([:secrets_config, Path.provider], env.secrets.dump_json(clean_unused_secrets) + "\n")
+ end
+ end
+
+ ##
+ ## FILTERING
+ ##
+
+ #
+ # returns a node list consisting only of nodes that satisfy the filter criteria.
+ #
+ # filter: condition [condition] [condition] [+condition]
+ # condition: [node_name | service_name | tag_name | environment_name]
+ #
+ # if conditions is prefixed with +, then it works like an AND. Otherwise, it works like an OR.
+ #
+ # args:
+ # filter -- array of filter terms, one per item
+ #
+ # options:
+ # :local -- if :local is false and the filter is empty, then local nodes are excluded.
+ # :nopin -- if true, ignore environment pinning
+ #
+ def filter(filters=nil, options={})
+ Filter.new(filters, options, self).nodes()
+ end
+
+ #
+ # same as filter(), but exits if there is no matching nodes
+ #
+ def filter!(filters, options={})
+ node_list = filter(filters, options)
+ Util::assert! node_list.any?, "Could not match any nodes from '#{filters.join ' '}'"
+ return node_list
+ end
+
+ #
+ # returns a single Config::Object that corresponds to a Node.
+ #
+ def node(name)
+ if name =~ /\./
+ # probably got a fqdn, since periods are not allowed in node names.
+ # so, take the part before the first period as the node name
+ name = name.split('.').first
+ end
+ env.nodes[name]
+ end
+
+ #
+ # returns a single node that is disabled
+ #
+ def disabled_node(name)
+ @disabled_nodes[name]
+ end
+
+ #
+ # yields each node, in sorted order
+ #
+ def each_node(&block)
+ env.nodes.each_node(&block)
+ end
+
+ def reload_node!(node)
+ env.nodes[node.name] = apply_inheritance!(node)
+ end
+
+ ##
+ ## CONNECTIONS
+ ##
+
+ class ConnectionList < Array
+ def add(data={})
+ self << {
+ "from" => data[:from],
+ "to" => data[:to],
+ "port" => data[:port]
+ }
+ end
+ end
+
+ def connections
+ @connections ||= ConnectionList.new
+ end
+
+ ##
+ ## PRIVATE
+ ##
+
+ private
+
+ #
+ # makes a node inherit options from appropriate the common, service, and tag json files.
+ #
+ def apply_inheritance(node, throw_exceptions=false)
+ new_node = Config::Node.new(nil)
+ node_env = guess_node_env(node)
+ new_node.set_environment(node_env, new_node)
+
+ # inherit from common
+ new_node.deep_merge!(node_env.common)
+
+ # inherit from services
+ if node['services']
+ node['services'].to_a.each do |node_service|
+ service = node_env.services[node_service]
+ if service.nil?
+ msg = 'in node "%s": the service "%s" does not exist.' % [node['name'], node_service]
+ LeapCli.log 0, :error, msg
+ raise LeapCli::ConfigError.new(node, "error " + msg) if throw_exceptions
+ else
+ new_node.deep_merge!(service)
+ end
+ end
+ end
+
+ # inherit from tags
+ node['tags'] = (node['tags'] || []).to_a
+ if node.vagrant?
+ node['tags'] << 'local'
+ elsif node['vm']
+ node['tags'] << 'vm'
+ end
+ node['tags'].each do |node_tag|
+ tag = node_env.tags[node_tag]
+ if tag.nil?
+ msg = 'in node `%s`: the tag "%s" does not exist!' % [node['name'], node_tag]
+ LeapCli.log 0, :error, msg
+ raise LeapCli::ConfigError.new(node, "error " + msg) if throw_exceptions
+ else
+ new_node.deep_merge!(tag)
+ end
+ end
+
+ # inherit from node
+ new_node.deep_merge!(node)
+ return new_node
+ end
+
+ def apply_inheritance!(node)
+ apply_inheritance(node, true)
+ end
+
+ #
+ # Guess the environment of the node from the tag names.
+ #
+ # Technically, this is wrong: a tag that sets the environment might not be
+ # named the same as the environment. This code assumes that it is.
+ #
+ # Unfortunately, it is a chicken and egg problem. We need to know the nodes
+ # likely environment in order to apply the inheritance that will actually
+ # determine the node's properties.
+ #
+ def guess_node_env(node)
+ if node.vagrant?
+ return self.env("local")
+ else
+ environment = self.env(default_environment)
+ if node['tags']
+ node['tags'].to_a.each do |tag|
+ if self.environment_names.include?(tag)
+ environment = self.env(tag)
+ end
+ end
+ end
+ return environment
+ end
+ end
+
+ #
+ # does some final clean at the end of loading nodes.
+ # this includes removing disabled nodes, and populating
+ # the services[x].node_list and tags[x].node_list
+ #
+ def cleanup_node_lists(options)
+ @disabled_nodes = Config::ObjectList.new
+ env.nodes.each do |name, node|
+ if node.enabled || options[:include_disabled]
+ if node['services']
+ node['services'].to_a.each do |node_service|
+ env(node.environment).services[node_service].node_list.add(node.name, node)
+ env('_all_').services[node_service].node_list.add(node.name, node)
+ end
+ end
+ if node['tags']
+ node['tags'].to_a.each do |node_tag|
+ if env(node.environment).tags[node_tag]
+ # if tag exists
+ env(node.environment).tags[node_tag].node_list.add(node.name, node)
+ env('_all_').tags[node_tag].node_list.add(node.name, node)
+ end
+ end
+ end
+ if node.name == 'default' || environment_names.include?(node.name)
+ LeapCli::Util.bail! do
+ LeapCli.log :error, "The node name '#{node.name}' is invalid, because there is an environment with that same name."
+ end
+ end
+ elsif !options[:include_disabled]
+ LeapCli.log 2, :skipping, "disabled node #{name}."
+ env.nodes.delete(name)
+ @disabled_nodes[name] = node
+ end
+ end
+ end
+
+ #
+ # Applies 'control' files for node .json files and provider.json.
+ #
+ # A control file is like a service or a tag JSON file, but it contains
+ # raw ruby code that gets evaluated in the context of the node.
+ #
+ # Yes, this entirely breaks our functional programming model for JSON
+ # generation.
+ #
+ # Control files are evaluated last, after everything else has run.
+ #
+ def apply_control_files
+ @environments.values.each do |e|
+ provider_control_files(e.name).each do |provider_rb|
+ begin
+ e.provider.eval_file provider_rb
+ rescue ConfigError => exc
+ if options[:continue_on_error]
+ exc.log
+ else
+ raise exc
+ end
+ end
+ end
+ end
+ env.nodes.each do |name, node|
+ node_control_files(node).each do |file|
+ begin
+ node.eval_file file
+ rescue ConfigError => exc
+ if options[:continue_on_error]
+ exc.log
+ else
+ raise exc
+ end
+ end
+ end
+ end
+ end
+
+ def node_control_files(node)
+ files = []
+ [Path.provider_base, Path.provider].each do |provider_dir|
+ # add common.rb
+ common = File.join(provider_dir, 'common.rb')
+ files << common if File.exist?(common)
+
+ # add services/*.rb and tags/*.rb, as appropriate for this node
+ [['services', :service_config], ['tags', :tag_config]].each do |attribute, path_sym|
+ node[attribute].each do |attr_value|
+ path = Path.named_path([path_sym, "#{attr_value}.rb"], provider_dir).sub(/\.json$/,'')
+ if File.exist?(path)
+ files << path
+ end
+ end
+ end
+ end
+ return files
+ end
+
+ def provider_control_files(env)
+ # skip envs that start with underscore
+ if env =~ /^_/
+ return []
+ end
+ files = []
+ environments = [nil]
+ environments << env unless env == 'default'
+ environments.each do |environment|
+ [Path.provider_base, Path.provider].each do |provider_dir|
+ provider_rb = File.join(
+ provider_dir, ['provider', environment, 'rb'].compact.join('.')
+ )
+ files << provider_rb if File.exist?(provider_rb)
+ end
+ end
+ return files
+ end
+
+ end
+ end
+end
diff --git a/lib/leap_cli/config/node.rb b/lib/leap_cli/config/node.rb
new file mode 100644
index 00000000..23abdee3
--- /dev/null
+++ b/lib/leap_cli/config/node.rb
@@ -0,0 +1,245 @@
+#
+# Configuration for a 'node' (a server in the provider's infrastructure)
+#
+
+require 'ipaddr'
+
+module LeapCli; module Config
+
+ class Node < Object
+ attr_accessor :file_paths
+
+ def initialize(environment=nil)
+ super(environment)
+ @node = self
+ @file_paths = []
+ end
+
+ #
+ # returns true if this node has an ip address in the range of the vagrant network
+ #
+ def vagrant?
+ ip = self['ip_address']
+ return false unless ip
+ begin
+ vagrant_range = IPAddr.new LeapCli.leapfile.vagrant_network
+ rescue ArgumentError
+ Util::bail! { Util::log :invalid, "vagrant_network in Leapfile or .leaprc" }
+ end
+
+ begin
+ ip_addr = IPAddr.new(ip)
+ rescue ArgumentError
+ Util::log :warning, "invalid ip address '#{ip}' for node '#{@node.name}'"
+ end
+ return vagrant_range.include?(ip_addr)
+ end
+
+ def vm?
+ self['vm']
+ end
+
+ def vm_id?
+ self['vm.id'] && !self['vm.id'].empty?
+ end
+
+ #
+ # Return a hash table representation of ourselves, with the key equal to the @node.name,
+ # and the value equal to the fields specified in *keys.
+ #
+ # Also, the result is flattened to a single hash, so a key of 'a.b' becomes 'a_b'
+ #
+ # compare to Object#pick(*keys). This method is the sames as Config::ObjectList#pick_fields,
+ # but works on a single node.
+ #
+ # Example:
+ #
+ # node.pick('domain.internal') =>
+ #
+ # {
+ # 'node1': {
+ # 'domain_internal': 'node1.example.i'
+ # }
+ # }
+ #
+ def pick_fields(*keys)
+ {@node.name => self.pick(*keys)}
+ end
+
+ #
+ # can be overridden by the platform.
+ # returns a list of node names that should be tested before this node
+ #
+ def test_dependencies
+ []
+ end
+
+ # returns a string list of supported ssh host key algorithms for this node.
+ # or an empty string if it could not be determined
+ def supported_ssh_host_key_algorithms
+ require 'leap_cli/ssh'
+ @host_key_algo ||= LeapCli::SSH::Key.supported_host_key_algorithms(
+ Util.read_file([:node_ssh_pub_key, @node.name])
+ )
+ end
+
+ #
+ # Takes strings such as "openvpn.gateway_address:1.1.1.1"
+ # and converts this to data stored in this node.
+ #
+ def seed_from_args(args)
+ args.each do |seed|
+ key, value = seed.split(':', 2)
+ value = format_seed_value(value)
+ Util.assert! key =~ /^[0-9a-z\._]+$/, "illegal characters used in property '#{key}'"
+ if key =~ /\./
+ key_parts = key.split('.')
+ final_key = key_parts.pop
+ current_object = self
+ 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
+ self[key] = value
+ end
+ end
+ end
+
+ #
+ # Seeds values for this node from a template, based on the services.
+ # Values in the template will not override existing node values.
+ #
+ def seed_from_template
+ inherit_from!(manager.template('common'))
+ [self['services']].flatten.each do |service|
+ if service
+ template = manager.template(service)
+ if template
+ inherit_from!(template)
+ end
+ end
+ end
+ end
+
+ #
+ # bails if the node is not valid.
+ #
+ def validate!
+ #
+ # validate ip_address
+ #
+ if self['ip_address'] == "REQUIRED"
+ Util.bail! do
+ Util.log :error, "ip_address is not set. " +
+ "Specify with `leap node add NAME ip_address:ADDRESS`."
+ end
+ elsif self['ip_address']
+ begin
+ IPAddr.new(self['ip_address'])
+ rescue ArgumentError
+ Util.bail! do
+ Util.log :invalid, "ip_address #{self['ip_address'].inspect}"
+ end
+ end
+ end
+
+ #
+ # validate name
+ #
+ self.class.validate_name!(self.name, self.vagrant?)
+ end
+
+ #
+ # create or update all the configs needed for this node,
+ # including x.509 certs as needed.
+ #
+ # note: this method will write to disk EVERYTHING
+ # in the node, which is not what you want
+ # if the node has inheritance applied.
+ #
+ def write_configs
+ json = self.dump_json(:exclude => ['name'])
+ Util.write_file!([:node_config, name], json + "\n")
+ rescue LeapCli::ConfigError
+ Config::Node.remove_node_files(self.name)
+ end
+
+ #
+ # modifies the config file nodes/NAME.json for this node.
+ #
+ def update_json(new_values)
+ self.env.update_node_json(node, new_values)
+ end
+
+ #
+ # returns an array of all possible dns names for this node
+ #
+ def all_dns_names
+ names = [@node.domain.internal, @node.domain.full]
+ if @node['dns'] && @node.dns['aliases'] && @node.dns.aliases.any?
+ names += @node.dns.aliases
+ end
+ names.compact!
+ names.sort!
+ names.uniq!
+ return names
+ end
+
+ def remove_files
+ self.class.remove_node_files(self.name)
+ end
+
+ ##
+ ## Class Methods
+ ##
+
+ def self.remove_node_files(node_name)
+ (Leap::Platform.node_files + [:node_files_dir]).each do |path|
+ Util.remove_file! [path, node_name]
+ end
+ end
+
+ def self.validate_name!(name, local=false)
+ Util.assert! name, 'Node is missing a name.'
+ if local
+ Util.assert! name =~ /^[0-9a-z]+$/,
+ "illegal characters used in node name '#{name}' " +
+ "(note: Vagrant does not allow hyphens or underscores)"
+ else
+ Util.assert! name =~ /^[0-9a-z-]+$/,
+ "illegal characters used in node name '#{name}' " +
+ "(note: Linux does not allow underscores)"
+ end
+ end
+
+ private
+
+ #
+ # 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
+
+ end
+
+end; end
diff --git a/lib/leap_cli/config/node_cert.rb b/lib/leap_cli/config/node_cert.rb
new file mode 100644
index 00000000..da63d621
--- /dev/null
+++ b/lib/leap_cli/config/node_cert.rb
@@ -0,0 +1,124 @@
+#
+# x509 related methods for Config::Node
+#
+module LeapCli; module Config
+
+ class Node < Object
+
+ #
+ # creates a new server certificate file for this node
+ #
+ def generate_cert
+ require 'leap_cli/x509'
+
+ if self['x509.use'] == false ||
+ !Util.file_exists?(:ca_cert, :ca_key) ||
+ !self.cert_needs_updating?
+ return false
+ end
+
+ cert = CertificateAuthority::Certificate.new
+ provider = env.provider
+
+ # set subject
+ cert.subject.common_name = self.domain.full
+ cert.serial_number.number = X509.cert_serial_number(self.domain.full)
+
+ # set expiration
+ cert.not_before = X509.yesterday
+ cert.not_after = X509.yesterday_advance(provider.ca.server_certificates.life_span)
+
+ # generate key
+ cert.key_material.generate_key(provider.ca.server_certificates.bit_size)
+
+ # sign
+ cert.parent = X509.ca_root
+ cert.sign!(X509.server_signing_profile(self))
+
+ # save
+ Util.write_file!([:node_x509_key, self.name], cert.key_material.private_key.to_pem)
+ Util.write_file!([:node_x509_cert, self.name], cert.to_pem)
+ end
+
+ #
+ # returns true if the certs associated with +node+ need to be regenerated.
+ #
+ def cert_needs_updating?(log_comments=true)
+ require 'leap_cli/x509'
+
+ if log_comments
+ def log(*args, &block)
+ Util.log(*args, &block)
+ end
+ else
+ def log(*args); end
+ end
+
+ node = self
+ if !Util.file_exists?([:node_x509_cert, node.name], [:node_x509_key, node.name])
+ return true
+ else
+ cert = X509.load_certificate_file([:node_x509_cert, node.name])
+ if !X509.created_by_authority?(cert)
+ 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
+ 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 != node.all_dns_names
+ log :updating, "cert for node '#{node.name}' because domain name aliases have changed" do
+ log "from: #{dns_names.inspect}"
+ log "to: #{node.all_dns_names.inspect})"
+ end
+ return true
+ end
+ end
+ end
+ end
+ return false
+ end
+
+ #
+ # check the expiration of commercial certs, if any.
+ #
+ def warn_if_commercial_cert_will_soon_expire
+ require 'leap_cli/x509'
+
+ self.all_dns_names.each do |domain|
+ if Util.file_exists?([:commercial_cert, domain])
+ cert = X509.load_certificate_file([:commercial_cert, domain])
+ path = Path.relative_path([:commercial_cert, domain])
+ if cert.not_after < Time.now.utc
+ Util.log :error, "the commercial certificate '#{path}' has EXPIRED! " +
+ "You should renew it with `leap cert renew #{domain}`."
+ elsif cert.not_after < Time.now.advance(:months => 2)
+ Util.log :warning, "the commercial certificate '#{path}' will expire soon (#{cert.not_after}). "+
+ "You should renew it with `leap cert renew #{domain}`."
+ end
+ end
+ end
+ end
+
+ end
+
+end; end
+
diff --git a/lib/leap_cli/config/object.rb b/lib/leap_cli/config/object.rb
new file mode 100644
index 00000000..16c41999
--- /dev/null
+++ b/lib/leap_cli/config/object.rb
@@ -0,0 +1,454 @@
+# encoding: utf-8
+
+require 'erb'
+require 'json/pure' # pure ruby implementation is required for our sorted trick to work.
+
+if $ruby_version < [1,9]
+ $KCODE = 'UTF8'
+end
+require 'ya2yaml' # pure ruby yaml
+
+module LeapCli
+ module Config
+
+ #
+ # This class represents the configuration for a single node, service, or tag.
+ # Also, all the nested hashes are also of this type.
+ #
+ # It is called 'object' because it corresponds to an Object in JSON.
+ #
+ class Object < Hash
+
+ attr_reader :env
+ attr_reader :node
+
+ def initialize(environment=nil, node=nil)
+ raise ArgumentError unless environment.nil? || environment.is_a?(Config::Environment)
+ @env = environment
+ # an object that is a node as @node equal to self, otherwise all the
+ # child objects point back to the top level node.
+ @node = node || self
+ end
+
+ def manager
+ @env.manager
+ end
+
+ #
+ # TODO: deprecate node.global()
+ #
+ def global
+ @env
+ end
+
+ def environment=(e)
+ self.store('environment', e)
+ end
+
+ def environment
+ self['environment']
+ end
+
+ def duplicate(env)
+ new_object = self.deep_dup
+ new_object.set_environment(env, new_object)
+ end
+
+ #
+ # export YAML
+ #
+ # We use pure ruby yaml exporter ya2yaml instead of SYCK or PSYCH because it
+ # allows us greater compatibility regardless of installed ruby version and
+ # greater control over how the yaml is exported (sorted keys, in particular).
+ #
+ def dump_yaml
+ evaluate(@node)
+ sorted_ya2yaml(:syck_compatible => true)
+ end
+
+ #
+ # export JSON
+ #
+ def dump_json(options={})
+ evaluate(@node)
+ if options[:format] == :compact
+ return self.to_json
+ else
+ excluded = {}
+ if options[:exclude]
+ options[:exclude].each do |key|
+ excluded[key] = self[key]
+ self.delete(key)
+ end
+ end
+ json_str = JSON.sorted_generate(self)
+ if excluded.any?
+ self.merge!(excluded)
+ end
+ return json_str
+ end
+ end
+
+ def evaluate(context=@node)
+ evaluate_everything(context)
+ late_evaluate_everything(context)
+ end
+
+ ##
+ ## FETCHING VALUES
+ ##
+
+ def [](key)
+ get(key)
+ end
+
+ # Overrride some default methods in Hash that are likely to
+ # be used as attributes.
+ alias_method :hkey, :key
+ def key; get('key'); end
+
+ #
+ # make hash addressable like an object (e.g. obj['name'] available as obj.name)
+ #
+ def method_missing(method, *args, &block)
+ get!(method)
+ end
+
+ def get(key)
+ begin
+ get!(key)
+ rescue NoMethodError
+ nil
+ end
+ end
+
+ # override behavior of #default() from Hash
+ def default
+ get!('default')
+ end
+
+ #
+ # Like a normal Hash#[], except:
+ #
+ # (1) lazily eval dynamic values when we encounter them. (i.e. strings that start with "= ")
+ #
+ # (2) support for nested references in a single string (e.g. ['a.b'] is the same as ['a']['b'])
+ # the dot path is always absolute, starting at the top-most object.
+ #
+ def get!(key)
+ key = key.to_s
+ if self.has_key?(key)
+ fetch_value(key)
+ elsif key =~ /\./
+ # for keys with with '.' in them, we start from the root object (@node).
+ keys = key.split('.')
+ value = self.get!(keys.first)
+ if value.is_a? Config::Object
+ value.get!(keys[1..-1].join('.'))
+ else
+ value
+ end
+ else
+ raise NoMethodError.new(key, "No method '#{key}' for #{self.class}")
+ end
+ end
+
+ #
+ # works like Hash#store(key, value), but supports our nested dot notation,
+ # just like get() does.
+ #
+ def set(key, value)
+ key = key.to_s
+ # for keys with with '.' in them, we pop off the first part
+ # and recursively call ourselves.
+ if key =~ /\./
+ keys = key.split('.')
+ parent_value = self.get!(keys.first)
+ if parent_value.is_a?(Config::Object)
+ parent_value.set(keys[1..-1].join('.'), value)
+ else
+ parent_value.store(keys[1..-1].join('.'), value)
+ end
+ else
+ self.store(key, value)
+ end
+ return nil
+ end
+
+ ##
+ ## COPYING
+ ##
+
+ #
+ # A deep (recursive) merge with another Config::Object.
+ #
+ # If prefer_self is set to true, the value from self will be picked when there is a conflict
+ # that cannot be merged.
+ #
+ # Merging rules:
+ #
+ # - If a value is a hash, we recursively merge it.
+ # - If the value is simple, like a string, the new one overwrites the value.
+ # - If the value is an array:
+ # - If both old and new values are arrays, the new one replaces the old.
+ # - If one of the values is simple but the other is an array, the simple is added to the array.
+ #
+ def deep_merge!(object, prefer_self=false)
+ object.each do |key,new_value|
+ if self.has_key?('+'+key)
+ mode = :add
+ old_value = self.fetch '+'+key, nil
+ self.delete('+'+key)
+ elsif self.has_key?('-'+key)
+ mode = :subtract
+ old_value = self.fetch '-'+key, nil
+ self.delete('-'+key)
+ elsif self.has_key?('!'+key)
+ mode = :replace
+ old_value = self.fetch '!'+key, nil
+ self.delete('!'+key)
+ else
+ mode = :normal
+ old_value = self.fetch key, nil
+ end
+
+ # clean up boolean
+ new_value = true if new_value == "true"
+ new_value = false if new_value == "false"
+ old_value = true if old_value == "true"
+ old_value = false if old_value == "false"
+
+ # force replace?
+ if mode == :replace && prefer_self
+ value = old_value
+
+ # merge hashes
+ elsif old_value.is_a?(Hash) || new_value.is_a?(Hash)
+ value = Config::Object.new(@env, @node)
+ old_value.is_a?(Hash) ? value.deep_merge!(old_value) : (value[key] = old_value if !old_value.nil?)
+ new_value.is_a?(Hash) ? value.deep_merge!(new_value, prefer_self) : (value[key] = new_value if !new_value.nil?)
+
+ # merge nil
+ elsif new_value.nil?
+ value = old_value
+ elsif old_value.nil?
+ value = new_value
+
+ # merge arrays when one value is not an array
+ elsif old_value.is_a?(Array) && !new_value.is_a?(Array)
+ (value = (old_value.dup << new_value).compact.uniq).delete('REQUIRED')
+ elsif new_value.is_a?(Array) && !old_value.is_a?(Array)
+ (value = (new_value.dup << old_value).compact.uniq).delete('REQUIRED')
+
+ # merge two arrays
+ elsif old_value.is_a?(Array) && new_value.is_a?(Array)
+ if mode == :add
+ value = (old_value + new_value).sort.uniq
+ elsif mode == :subtract
+ value = new_value - old_value
+ elsif prefer_self
+ value = old_value
+ else
+ value = new_value
+ end
+
+ # catch errors
+ elsif type_mismatch?(old_value, new_value)
+ raise 'Type mismatch. Cannot merge %s (%s) with %s (%s). Key is "%s", name is "%s".' % [
+ old_value.inspect, old_value.class,
+ new_value.inspect, new_value.class,
+ key, self.class
+ ]
+
+ # merge simple strings & numbers
+ else
+ if prefer_self
+ value = old_value
+ else
+ value = new_value
+ end
+ end
+
+ # save value
+ self[key] = value
+ end
+ self
+ end
+
+ def set_environment(env, node)
+ @env = env
+ @node = node
+ self.each do |key, value|
+ if value.is_a?(Config::Object)
+ value.set_environment(env, node)
+ end
+ end
+ end
+
+ #
+ # like a reverse deep merge
+ # (self takes precedence)
+ #
+ def inherit_from!(object)
+ self.deep_merge!(object, true)
+ end
+
+ #
+ # Make a copy of ourselves, except only including the specified keys.
+ #
+ # Also, the result is flattened to a single hash, so a key of 'a.b' becomes 'a_b'
+ #
+ def pick(*keys)
+ keys.map(&:to_s).inject(self.class.new(@manager)) do |hsh, key|
+ value = self.get(key)
+ if !value.nil?
+ hsh[key.gsub('.','_')] = value
+ end
+ hsh
+ end
+ end
+
+ def eval_file(filename)
+ evaluate_ruby(filename, File.read(filename))
+ end
+
+ protected
+
+ #
+ # walks the object tree, eval'ing all the attributes that are dynamic ruby (e.g. value starts with '= ')
+ #
+ def evaluate_everything(context)
+ keys.each do |key|
+ obj = fetch_value(key, context)
+ if is_required_value_not_set?(obj)
+ Util::log 0, :warning, "required property \"#{key}\" is not set in node \"#{node.name}\"."
+ elsif obj.is_a? Config::Object
+ obj.evaluate_everything(context)
+ end
+ end
+ end
+
+ #
+ # some keys need to be evaluated 'late', after all the other keys have been evaluated.
+ #
+ def late_evaluate_everything(context)
+ if @late_eval_list
+ @late_eval_list.each do |key, value|
+ self[key] = context.evaluate_ruby(key, value)
+ if is_required_value_not_set?(self[key])
+ Util::log 0, :warning, "required property \"#{key}\" is not set in node \"#{node.name}\"."
+ end
+ end
+ end
+ values.each do |obj|
+ if obj.is_a? Config::Object
+ obj.late_evaluate_everything(context)
+ end
+ end
+ end
+
+ #
+ # evaluates the string `value` as ruby in the context of self.
+ # (`key` is just passed for debugging purposes)
+ #
+ def evaluate_ruby(key, value)
+ self.instance_eval(value, key, 1)
+ rescue ConfigError => exc
+ raise exc # pass through
+ rescue SystemStackError => exc
+ Util::log 0, :error, "while evaluating node '#{self.name}'"
+ Util::log 0, "offending key: #{key}", :indent => 1
+ Util::log 0, "offending string: #{value}", :indent => 1
+ Util::log 0, "STACK OVERFLOW, BAILING OUT. There must be an eval loop of death (variables with circular dependencies).", :indent => 1
+ raise SystemExit.new(1)
+ rescue FileMissing => exc
+ Util::bail! do
+ if exc.options[:missing]
+ Util::log :missing, exc.options[:missing].gsub('$node', self.name).gsub('$file', exc.path)
+ else
+ Util::log :error, "while evaluating node '#{self.name}'"
+ Util::log "offending key: #{key}", :indent => 1
+ Util::log "offending string: #{value}", :indent => 1
+ Util::log "error message: no file '#{exc}'", :indent => 1
+ end
+ raise exc if DEBUG
+ end
+ rescue AssertionFailed => exc
+ Util.bail! do
+ Util::log :failed, "assertion while evaluating node '#{self.name}'"
+ Util::log 'assertion: %s' % exc.assertion, :indent => 1
+ Util::log "offending key: #{key}", :indent => 1
+ raise exc if DEBUG
+ end
+ rescue SyntaxError, StandardError => exc
+ Util::bail! do
+ Util::log :error, "while evaluating node '#{self.name}'"
+ Util::log "offending key: #{key}", :indent => 1
+ Util::log "offending string: #{value}", :indent => 1
+ Util::log "error message: #{exc.inspect}", :indent => 1
+ raise exc if DEBUG
+ end
+ end
+
+ private
+
+ #
+ # fetches the value for the key, evaluating the value as ruby if it begins with '='
+ #
+ def fetch_value(key, context=@node)
+ value = fetch(key, nil)
+ if value.is_a?(String) && value =~ /^=/
+ # strings prefix with '=' are evaluated as ruby code.
+ if value =~ /^=> (.*)$/
+ value = evaluate_later(key, $1)
+ elsif value =~ /^= (.*)$/
+ value = context.evaluate_ruby(key, $1)
+ end
+ self[key] = value
+ elsif value.is_a?(Proc)
+ # the value might be a proc, set by a 'control' file
+ self[key] = value.call
+ end
+ return value
+ end
+
+ def evaluate_later(key, value)
+ @late_eval_list ||= []
+ @late_eval_list << [key, value]
+ '<evaluate later>'
+ end
+
+ #
+ # when merging, we raise an error if this method returns true for the two values.
+ #
+ def type_mismatch?(old_value, new_value)
+ if old_value.is_a?(Boolean) && new_value.is_a?(Boolean)
+ # note: FalseClass and TrueClass are different classes
+ # so we can't do old_value.class == new_value.class
+ return false
+ elsif old_value.is_a?(String) && old_value =~ /^=/
+ # pass through macros, since we don't know what the type will eventually be.
+ return false
+ elsif new_value.is_a?(String) && new_value =~ /^=/
+ return false
+ elsif old_value.class == new_value.class
+ return false
+ else
+ return true
+ end
+ end
+
+ #
+ # returns true if the value has not been changed and the default is "REQUIRED"
+ #
+ def is_required_value_not_set?(value)
+ if value.is_a? Array
+ value == ["REQUIRED"]
+ else
+ value == "REQUIRED"
+ end
+ end
+
+ end # class
+ end # module
+end # module \ No newline at end of file
diff --git a/lib/leap_cli/config/object_list.rb b/lib/leap_cli/config/object_list.rb
new file mode 100644
index 00000000..80f89d92
--- /dev/null
+++ b/lib/leap_cli/config/object_list.rb
@@ -0,0 +1,215 @@
+require 'tsort'
+
+module LeapCli
+ module Config
+ #
+ # A list of Config::Object instances (internally stored as a hash)
+ #
+ class ObjectList < Hash
+ include TSort
+
+ def initialize(config=nil)
+ if config
+ self.add(config['name'], config)
+ end
+ end
+
+ #
+ # If the key is a string, the Config::Object it references is returned.
+ #
+ # If the key is a hash, we treat it as a condition and filter all the Config::Objects using the condition.
+ # A new ObjectList is returned.
+ #
+ # Examples:
+ #
+ # nodes['vpn1']
+ # node named 'vpn1'
+ #
+ # nodes[:public_dns => true]
+ # all nodes with public dns
+ #
+ # nodes[:services => 'openvpn', 'location.country_code' => 'US']
+ # all nodes with services containing 'openvpn' OR country code of US
+ #
+ # Sometimes, you want to do an OR condition with multiple conditions
+ # for the same field. Since hash keys must be unique, you can use
+ # an array representation instead:
+ #
+ # nodes[[:services, 'openvpn'], [:services, 'tor']]
+ # nodes with openvpn OR tor service
+ #
+ # nodes[:services => 'openvpn'][:tags => 'production']
+ # nodes with openvpn AND are production
+ #
+ def [](key)
+ if key.is_a?(Hash) || key.is_a?(Array)
+ filter(key)
+ else
+ super key.to_s
+ end
+ end
+
+ def exclude(node)
+ list = self.dup
+ list.delete(node.name)
+ return list
+ end
+
+ def each_node(&block)
+ self.keys.sort.each do |node_name|
+ yield self[node_name]
+ end
+ end
+
+ #
+ # filters this object list, producing a new list.
+ # filter is an array or a hash. see []
+ #
+ def filter(filter)
+ results = Config::ObjectList.new
+ filter.each do |field, match_value|
+ field = field.is_a?(Symbol) ? field.to_s : field
+ match_value = match_value.is_a?(Symbol) ? match_value.to_s : match_value
+ if match_value.is_a?(String) && match_value =~ /^!/
+ operator = :not_equal
+ match_value = match_value.sub(/^!/, '')
+ else
+ operator = :equal
+ end
+ each do |name, config|
+ value = config[field]
+ if value.is_a? Array
+ if operator == :equal && value.include?(match_value)
+ results[name] = config
+ elsif operator == :not_equal && !value.include?(match_value)
+ results[name] = config
+ end
+ elsif match_value.is_a? Array
+ if operator == :equal && match_value.include?(value)
+ results[name] = config
+ elsif operator == :not_equal && !match_value.include?(value)
+ results[name] = config
+ end
+ else
+ if operator == :equal && value == match_value
+ results[name] = config
+ elsif operator == :not_equal && value != match_value
+ results[name] = config
+ end
+ end
+ end
+ end
+ results
+ end
+
+ def add(name, object)
+ self[name] = object
+ end
+
+ #
+ # converts the hash of configs into an array of hashes, with ONLY the specified fields
+ #
+ def fields(*fields)
+ result = []
+ keys.sort.each do |name|
+ result << self[name].pick(*fields)
+ end
+ result
+ end
+
+ #
+ # like fields(), but returns an array of values instead of an array of hashes.
+ #
+ def field(field)
+ field = field.to_s
+ result = []
+ keys.sort.each do |name|
+ result << self[name].get(field)
+ end
+ result
+ end
+
+ #
+ # pick_fields(field1, field2, ...)
+ #
+ # generates a Hash from the object list, but with only the fields that are picked.
+ #
+ # If there are more than one field, then the result is a Hash of Hashes.
+ # If there is just one field, it is a simple map to the value.
+ #
+ # For example:
+ #
+ # "neighbors" = "= nodes_like_me[:services => :couchdb].pick_fields('domain.full', 'ip_address')"
+ #
+ # generates this:
+ #
+ # neighbors:
+ # couch1:
+ # domain_full: couch1.bitmask.net
+ # ip_address: "10.5.5.44"
+ # couch2:
+ # domain_full: couch2.bitmask.net
+ # ip_address: "10.5.5.52"
+ #
+ # But this:
+ #
+ # "neighbors": "= nodes_like_me[:services => :couchdb].pick_fields('domain.full')"
+ #
+ # will generate this:
+ #
+ # neighbors:
+ # couch1: couch1.bitmask.net
+ # couch2: couch2.bitmask.net
+ #
+ def pick_fields(*fields)
+ self.values.inject({}) do |hsh, node|
+ value = self[node.name].pick(*fields)
+ if fields.size == 1
+ value = value.values.first
+ end
+ hsh[node.name] = value
+ hsh
+ end
+ end
+
+ #
+ # Applies inherit_from! to all objects.
+ #
+ # 'env' specifies what environment should be for
+ # each object in the list.
+ #
+ def inherit_from!(object_list, env)
+ object_list.each do |name, object|
+ if self[name]
+ self[name].inherit_from!(object)
+ else
+ self[name] = object.duplicate(env)
+ end
+ end
+ end
+
+ #
+ # topographical sort based on test dependency
+ #
+ def tsort_each_node(&block)
+ self.each_key(&block)
+ end
+
+ def tsort_each_child(node_name, &block)
+ if self[node_name]
+ self[node_name].test_dependencies.each do |test_me_first|
+ if self[test_me_first] # TODO: in the future, allow for ability to optionally pull in all dependencies.
+ # not just the ones that pass the node filter.
+ yield(test_me_first)
+ end
+ end
+ end
+ end
+
+ def names_in_test_dependency_order
+ self.tsort
+ end
+
+ end
+ end
+end
diff --git a/lib/leap_cli/config/provider.rb b/lib/leap_cli/config/provider.rb
new file mode 100644
index 00000000..0d8bc1f3
--- /dev/null
+++ b/lib/leap_cli/config/provider.rb
@@ -0,0 +1,22 @@
+#
+# Configuration class for provider.json
+#
+
+module LeapCli; module Config
+ class Provider < Object
+ attr_reader :environment
+ def set_env(e)
+ if e == 'default'
+ @environment = nil
+ else
+ @environment = e
+ end
+ end
+ def provider
+ self
+ end
+ def validate!
+ # nothing here yet :(
+ end
+ end
+end; end
diff --git a/lib/leap_cli/config/secrets.rb b/lib/leap_cli/config/secrets.rb
new file mode 100644
index 00000000..ca851c74
--- /dev/null
+++ b/lib/leap_cli/config/secrets.rb
@@ -0,0 +1,87 @@
+# encoding: utf-8
+#
+# A class for the secrets.json file
+#
+
+module LeapCli; module Config
+
+ class Secrets < Object
+ attr_reader :node_list
+
+ def initialize(manager=nil)
+ super(manager)
+ @discovered_keys = {}
+ end
+
+ # we can't use fetch() or get(), since those already have special meanings
+ def retrieve(key, environment)
+ environment ||= 'default'
+ self.fetch(environment, {})[key.to_s]
+ end
+
+ def set(*args, &block)
+ if block_given?
+ set_with_block(*args, &block)
+ else
+ set_without_block(*args)
+ end
+ end
+
+ # searches over all keys matching the regexp, checking to see if the value
+ # has been already used by any of them.
+ def taken?(regexp, value, environment)
+ self.keys.grep(regexp).each do |key|
+ return true if self.retrieve(key, environment) == value
+ end
+ return false
+ end
+
+ def set_without_block(key, value, environment)
+ set_with_block(key, environment) {value}
+ end
+
+ def set_with_block(key, environment, &block)
+ environment ||= 'default'
+ key = key.to_s
+ @discovered_keys[environment] ||= {}
+ @discovered_keys[environment][key] = true
+ self[environment] ||= {}
+ self[environment][key] ||= yield
+ end
+
+ #
+ # if clean is true, then only secrets that have been discovered
+ # during this run will be exported.
+ #
+ # if environment is also pinned, then we will clean those secrets
+ # just for that environment.
+ #
+ # the clean argument should only be used when all nodes have
+ # been processed, otherwise secrets that are actually in use will
+ # get mistakenly removed.
+ #
+ def dump_json(clean=false)
+ pinned_env = LeapCli.leapfile.environment
+ if clean
+ self.each_key do |environment|
+ if pinned_env.nil? || pinned_env == environment
+ env = self[environment]
+ if env.nil?
+ raise StandardError.new("secrets.json file seems corrupted. No such environment '#{environment}'")
+ end
+ env.each_key do |key|
+ unless @discovered_keys[environment] && @discovered_keys[environment][key]
+ self[environment].delete(key)
+ end
+ end
+ if self[environment].empty?
+ self.delete(environment)
+ end
+ end
+ end
+ end
+ super()
+ end
+ end
+
+end; end
diff --git a/lib/leap_cli/config/sources.rb b/lib/leap_cli/config/sources.rb
new file mode 100644
index 00000000..aee860de
--- /dev/null
+++ b/lib/leap_cli/config/sources.rb
@@ -0,0 +1,11 @@
+# encoding: utf-8
+#
+# A class for the sources.json file
+#
+
+module LeapCli
+ module Config
+ class Sources < Object
+ end
+ end
+end
diff --git a/lib/leap_cli/config/tag.rb b/lib/leap_cli/config/tag.rb
new file mode 100644
index 00000000..6bd8d1e9
--- /dev/null
+++ b/lib/leap_cli/config/tag.rb
@@ -0,0 +1,25 @@
+#
+#
+# A class for node services or node tags.
+#
+#
+
+module LeapCli; module Config
+
+ class Tag < Object
+ attr_reader :node_list
+
+ def initialize(environment=nil)
+ super(environment)
+ @node_list = Config::ObjectList.new
+ end
+
+ # don't copy the node list pointer when this object is dup'ed.
+ def initialize_copy(orig)
+ super
+ @node_list = Config::ObjectList.new
+ end
+
+ end
+
+end; end
diff --git a/lib/leap_cli/leapfile_extensions.rb b/lib/leap_cli/leapfile_extensions.rb
new file mode 100644
index 00000000..cba321f4
--- /dev/null
+++ b/lib/leap_cli/leapfile_extensions.rb
@@ -0,0 +1,24 @@
+module LeapCli
+ class Leapfile
+ attr_reader :custom_vagrant_vm_line
+ attr_reader :leap_version
+ attr_reader :log
+ attr_reader :vagrant_basebox
+
+ def vagrant_network
+ @vagrant_network ||= '10.5.5.0/24'
+ end
+
+ private
+
+ PRIVATE_IP_RANGES = /(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/
+
+ def validate
+ Util::assert! vagrant_network =~ PRIVATE_IP_RANGES do
+ Util::log 0, :error, "in #{file}: vagrant_network is not a local private network"
+ end
+ return true
+ end
+
+ end
+end
diff --git a/lib/leap_cli/load_libraries.rb b/lib/leap_cli/load_libraries.rb
new file mode 100644
index 00000000..01384f78
--- /dev/null
+++ b/lib/leap_cli/load_libraries.rb
@@ -0,0 +1,23 @@
+#
+# load the commonly needed leap_cli libraries that live in the platform.
+#
+# loaded by leap_cli's bootstrap.rb
+#
+
+require 'leap_cli/log_filter'
+
+require 'leap_cli/config/object'
+require 'leap_cli/config/node'
+require 'leap_cli/config/node_cert'
+require 'leap_cli/config/tag'
+require 'leap_cli/config/provider'
+require 'leap_cli/config/secrets'
+require 'leap_cli/config/cloud'
+require 'leap_cli/config/object_list'
+require 'leap_cli/config/filter'
+require 'leap_cli/config/environment'
+require 'leap_cli/config/manager'
+
+require 'leap_cli/util/secret'
+require 'leap_cli/util/vagrant'
+require 'leap_cli/util/console_table'
diff --git a/lib/leap_cli/log_filter.rb b/lib/leap_cli/log_filter.rb
new file mode 100644
index 00000000..c73f3a91
--- /dev/null
+++ b/lib/leap_cli/log_filter.rb
@@ -0,0 +1,176 @@
+#
+# A module to hide, modify, and colorize log entries.
+#
+
+module LeapCli
+ module LogFilter
+ #
+ # options for formatters:
+ #
+ # :match => regexp for matching a log line
+ # :color => what color the line should be
+ # :style => what style the line should be
+ # :priority => what order the formatters are applied in. higher numbers first.
+ # :match_level => only apply filter at the specified log level
+ # :level => make this line visible at this log level or higher
+ # :replace => replace the matched text
+ # :prepend => insert text at start of message
+ # :append => append text to end of message
+ # :exit => force the exit code to be this (does not interrupt program, just
+ # ensures a specific exit code when the program eventually exits)
+ #
+ FORMATTERS = [
+ # TRACE
+ { :match => /command finished/, :color => :white, :style => :dim, :match_level => 3, :priority => -10 },
+ { :match => /executing locally/, :color => :yellow, :match_level => 3, :priority => -20 },
+
+ # DEBUG
+ #{ :match => /executing .*/, :color => :green, :match_level => 2, :priority => -10, :timestamp => true },
+ #{ :match => /.*/, :color => :yellow, :match_level => 2, :priority => -30 },
+ { :match => /^transaction:/, :level => 3 },
+
+ # INFO
+ { :match => /.*out\] (fatal:|ERROR:).*/, :color => :red, :match_level => 1, :priority => -10 },
+ { :match => /Permission denied/, :color => :red, :match_level => 1, :priority => -20 },
+ { :match => /sh: .+: command not found/, :color => :magenta, :match_level => 1, :priority => -30 },
+
+ # IMPORTANT
+ { :match => /^(E|e)rr ::/, :color => :red, :match_level => 0, :priority => -10, :exit => 1},
+ { :match => /^ERROR:/, :color => :red, :priority => -10, :exit => 1},
+ #{ :match => /.*/, :color => :blue, :match_level => 0, :priority => -20 },
+
+ # CLEANUP
+ #{ :match => /\s+$/, :replace => '', :priority => 0},
+
+ # DEBIAN PACKAGES
+ { :match => /^(Hit|Ign) /, :color => :green, :priority => -20},
+ { :match => /^Err /, :color => :red, :priority => -20},
+ { :match => /^W(ARNING)?: /, :color => :yellow, :priority => -20},
+ { :match => /^E: /, :color => :red, :priority => -20},
+ { :match => /already the newest version/, :color => :green, :priority => -20},
+ { :match => /WARNING: The following packages cannot be authenticated!/, :color => :red, :level => 0, :priority => -10},
+
+ # PUPPET
+ { :match => /^(W|w)arning: Not collecting exported resources without storeconfigs/, :level => 2, :color => :yellow, :priority => -10},
+ { :match => /^(W|w)arning: Found multiple default providers for vcsrepo:/, :level => 2, :color => :yellow, :priority => -10},
+ { :match => /^(W|w)arning: .*is deprecated.*$/, :level => 2, :color => :yellow, :priority => -10},
+ { :match => /^(W|w)arning: Scope.*$/, :level => 2, :color => :yellow, :priority => -10},
+ #{ :match => /^(N|n)otice:/, :level => 1, :color => :cyan, :priority => -20},
+ #{ :match => /^(N|n)otice:.*executed successfully$/, :level => 2, :color => :cyan, :priority => -15},
+ { :match => /^(W|w)arning:/, :level => 0, :color => :yellow, :priority => -20},
+ { :match => /^Duplicate declaration:/, :level => 0, :color => :red, :priority => -20},
+ #{ :match => /Finished catalog run/, :level => 0, :color => :green, :priority => -10},
+ { :match => /^APPLY COMPLETE \(changes made\)/, :level => 0, :color => :green, :style => :bold, :priority => -10},
+ { :match => /^APPLY COMPLETE \(no changes\)/, :level => 0, :color => :green, :style => :bold, :priority => -10},
+
+ # PUPPET FATAL ERRORS
+ { :match => /^(E|e)rr(or|):/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Wrapped exception:/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Failed to parse template/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Execution of.*returned/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Parameter matches failed:/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Syntax error/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Cannot reassign variable/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^Could not find template/, :level => 0, :color => :red, :priority => -1, :exit => 1},
+ { :match => /^APPLY COMPLETE.*fail/, :level => 0, :color => :red, :style => :bold, :priority => -1, :exit => 1},
+
+ # TESTS
+ { :match => /^PASS: /, :color => :green, :priority => -20},
+ { :match => /^(FAIL|ERROR): /, :color => :red, :priority => -20},
+ { :match => /^(SKIP|WARN): /, :color => :yellow, :priority => -20},
+ { :match => /\d+ tests: \d+ passes, \d+ skips, 0 warnings, 0 failures, 0 errors/,
+ :color => :green, :style => :bold, :priority => -20 },
+ { :match => /\d+ tests: \d+ passes, \d+ skips, [1-9][0-9]* warnings, 0 failures, 0 errors/,
+ :color => :yellow, :style => :bold, :priority => -20 },
+ { :match => /\d+ tests: \d+ passes, \d+ skips, \d+ warnings, \d+ failures, [1-9][0-9]* errors/,
+ :color => :red, :style => :bold, :priority => -20 },
+ { :match => /\d+ tests: \d+ passes, \d+ skips, \d+ warnings, [1-9][0-9]* failures, \d+ errors/,
+ :color => :red, :style => :bold, :priority => -20 },
+
+ # LOG SUPPRESSION
+ { :match => /^(W|w)arning: You cannot collect without storeconfigs being set/, :level => 2, :priority => 10},
+ { :match => /^(W|w)arning: You cannot collect exported resources without storeconfigs being set/, :level => 2, :priority => 10}
+ ]
+
+ SORTED_FORMATTERS = FORMATTERS.sort_by { |i| -(i[:priority] || i[:prio] || 0) }
+
+ #
+ # same as normal formatters, but only applies to the title, not the message.
+ #
+ TITLE_FORMATTERS = [
+ # red
+ { :match => /fatal_error/, :replace => 'fatal error:', :color => :red, :style => :bold },
+ { :match => /error/, :color => :red, :style => :bold },
+ { :match => /removed/, :color => :red, :style => :bold },
+ { :match => /removing/, :color => :red, :style => :bold },
+ { :match => /destroyed/, :color => :red, :style => :bold },
+ { :match => /destroying/, :color => :red, :style => :bold },
+ { :match => /terminated/, :color => :red, :style => :bold },
+ { :match => /failed/, :replace => 'FAILED', :color => :red, :style => :bold },
+ { :match => /bailing/, :replace => 'bailing', :color => :red, :style => :bold },
+ { :match => /invalid/, :color => :red, :style => :bold },
+
+ # yellow
+ { :match => /warning/, :replace => 'warning:', :color => :yellow, :style => :bold },
+ { :match => /missing/, :color => :yellow, :style => :bold },
+ { :match => /skipping/, :color => :yellow, :style => :bold },
+
+ # green
+ { :match => /created/, :color => :green, :style => :bold },
+ { :match => /completed/, :color => :green, :style => :bold },
+ { :match => /ran/, :color => :green, :style => :bold },
+ { :match => /^registered/, :color => :green, :style => :bold },
+
+ # cyan
+ { :match => /note/, :replace => 'NOTE:', :color => :cyan, :style => :bold },
+
+ # magenta
+ { :match => /nochange/, :replace => 'no change', :color => :magenta },
+ { :match => /^loading/, :color => :magenta },
+ ]
+
+ def self.apply_message_filters(message)
+ return self.apply_filters(SORTED_FORMATTERS, message)
+ end
+
+ def self.apply_title_filters(title)
+ return self.apply_filters(TITLE_FORMATTERS, title)
+ end
+
+ private
+
+ def self.apply_filters(formatters, message)
+ level = LeapCli.logger.log_level
+ result = {}
+ formatters.each do |formatter|
+ if (formatter[:match_level] == level || formatter[:match_level].nil?)
+ if message =~ formatter[:match]
+ # puts "applying formatter #{formatter.inspect}"
+ result[:level] = formatter[:level] if formatter[:level]
+ result[:color] = formatter[:color] if formatter[:color]
+ result[:style] = formatter[:style] || formatter[:attribute] # (support original cap colors)
+
+ message.gsub!(formatter[:match], formatter[:replace]) if formatter[:replace]
+ message.replace(formatter[:prepend] + message) unless formatter[:prepend].nil?
+ message.replace(message + formatter[:append]) unless formatter[:append].nil?
+ message.replace(Time.now.strftime('%Y-%m-%d %T') + ' ' + message) if formatter[:timestamp]
+
+ if formatter[:exit]
+ LeapCli::Util.exit_status(formatter[:exit])
+ end
+
+ # stop formatting, unless formatter was just for string replacement
+ break unless formatter[:replace]
+ end
+ end
+ end
+
+ if result[:color] == :hide
+ return [nil, {}]
+ else
+ return [message, result]
+ end
+ end
+
+ end
+end
diff --git a/lib/leap_cli/macros.rb b/lib/leap_cli/macros.rb
deleted file mode 100644
index fdb9a94e..00000000
--- a/lib/leap_cli/macros.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-#
-# MACROS
-#
-# The methods in these files are available in the context of a .json configuration file.
-# (The module LeapCli::Macro is included in Config::Object)
-#
-
-require_relative 'macros/core'
-require_relative 'macros/files'
-require_relative 'macros/haproxy'
-require_relative 'macros/hosts'
-require_relative 'macros/keys'
-require_relative 'macros/nodes'
-require_relative 'macros/secrets'
-require_relative 'macros/stunnel'
-require_relative 'macros/provider'
diff --git a/lib/leap_cli/macros/files.rb b/lib/leap_cli/macros/files.rb
index 04c94edf..602fdddb 100644
--- a/lib/leap_cli/macros/files.rb
+++ b/lib/leap_cli/macros/files.rb
@@ -79,19 +79,26 @@ module LeapCli
#
# file_path(:dkim_priv_key) {generate_dkim_key}
#
- # notes:
+ # Notes:
#
- # * argument 'path' is relative to Path.provider/files or
- # Path.provider_base/files
- # * the path returned by this method is absolute
- # * the path stored for use later by rsync is relative to Path.provider
- # * if the path does not exist locally, but exists in provider_base,
+ # * Argument 'path' must be relative to Path.provider/files or
+ # Path.provider_base/files. It is OK for the path to be prefixed with
+ # with 'files/', this prefix will be ignored.
+ #
+ # * The path returned by this method is an absolute path on the server.
+ #
+ # * The path stored for use later by rsync is relative to the local
+ # Path.provider. It should always be prefixed with 'files/'
+ #
+ # * If the path does not exist locally, but exists in provider_base,
# then the default file from provider_base is copied locally. this
# is required for rsync to work correctly.
#
+ # NOTE: this is an aweful way to do this. It would be better
+ # to rsync twice.
+ #
def remote_file_path(path, options={}, &block)
local_path = local_file_path(path, options, &block)
-
return nil if local_path.nil?
# if file is under Path.provider_base, we must copy the default file to
@@ -110,9 +117,11 @@ module LeapCli
end
relative_path = Path.relative_path(local_path)
- relative_path.sub!(/^files\//, '') # remove "files/" prefix
@node.file_paths << relative_path
- return File.join(Leap::Platform.files_dir, relative_path)
+ return File.join(
+ Leap::Platform.files_dir,
+ relative_path.sub(/^files\//, '')
+ )
end
# deprecated
diff --git a/lib/leap_cli/macros/haproxy.rb b/lib/leap_cli/macros/haproxy.rb
index 602ae726..3fef24c4 100644
--- a/lib/leap_cli/macros/haproxy.rb
+++ b/lib/leap_cli/macros/haproxy.rb
@@ -26,7 +26,7 @@ module LeapCli
# create a simple map for node name -> local stunnel accept port
accept_ports = stunnel_clients.inject({}) do |hsh, stunnel_entry|
- name = stunnel_entry.first.sub /_[0-9]+$/, ''
+ name = stunnel_entry.first.sub(/_[0-9]+$/, '')
hsh[name] = stunnel_entry.last['accept_port']
hsh
end
diff --git a/lib/leap_cli/macros/keys.rb b/lib/leap_cli/macros/keys.rb
index e7a75cfb..9cc01fe7 100644
--- a/lib/leap_cli/macros/keys.rb
+++ b/lib/leap_cli/macros/keys.rb
@@ -29,7 +29,7 @@ module LeapCli
# generating key if it is missing
#
def tor_public_key_path(path_name, key_type)
- file_path(path_name) { generate_tor_key(key_type) }
+ remote_file_path(path_name) { generate_tor_key(key_type) }
end
#
@@ -37,7 +37,7 @@ module LeapCli
# generating key if it is missing
#
def tor_private_key_path(path_name, key_type)
- file_path(path_name) { generate_tor_key(key_type) }
+ remote_file_path(path_name) { generate_tor_key(key_type) }
end
#
@@ -55,15 +55,15 @@ module LeapCli
require 'base32'
require 'base64'
require 'openssl'
- path = Path.find_file([path_name, self.name])
- if path && File.exists?(path)
+ path = Path.named_path([path_name, self.name])
+ if path && File.exist?(path)
public_key_str = File.readlines(path).grep(/^[^-]/).join
public_key = Base64.decode64(public_key_str)
public_key = public_key.slice(22..-1) # Tor ignores the 22 byte SPKI header
sha1sum = Digest::SHA1.new.digest(public_key)
Base32.encode(sha1sum.slice(0,10)).downcase
else
- LeapCli.log :warning, 'Tor public key file "%s" does not exist' % tor_public_key_path
+ LeapCli.log :warning, 'Tor public key file "%s" does not exist' % path
end
end
diff --git a/lib/leap_cli/ssh.rb b/lib/leap_cli/ssh.rb
new file mode 100644
index 00000000..8b604d1d
--- /dev/null
+++ b/lib/leap_cli/ssh.rb
@@ -0,0 +1,7 @@
+require 'sshkit'
+require_relative 'ssh/options'
+require_relative 'ssh/backend'
+require_relative 'ssh/formatter'
+require_relative 'ssh/scripts'
+require_relative 'ssh/remote_command'
+require_relative 'ssh/key'
diff --git a/lib/leap_cli/ssh/backend.rb b/lib/leap_cli/ssh/backend.rb
new file mode 100644
index 00000000..3894d815
--- /dev/null
+++ b/lib/leap_cli/ssh/backend.rb
@@ -0,0 +1,209 @@
+#
+# A custome SSHKit backend, derived from the default netssh backend.
+# Our custom backend modifies the logging behavior and gracefully captures
+# common exceptions.
+#
+
+require 'stringio'
+require 'timeout'
+require 'sshkit'
+require 'leap_cli/ssh/formatter'
+require 'leap_cli/ssh/scripts'
+
+module SSHKit
+ class Command
+ #
+ # override exit_status in order to be less verbose
+ #
+ def exit_status=(new_exit_status)
+ @finished_at = Time.now
+ @exit_status = new_exit_status
+ if options[:raise_on_non_zero_exit] && exit_status > 0
+ message = ""
+ message += "exit status: " + exit_status.to_s + "\n"
+ message += "stdout: " + (full_stdout.strip.empty? ? "Nothing written" : full_stdout.strip) + "\n"
+ message += "stderr: " + (full_stderr.strip.empty? ? 'Nothing written' : full_stderr.strip) + "\n"
+ raise Failed, message
+ end
+ end
+ end
+end
+
+module LeapCli
+ module SSH
+ class Backend < SSHKit::Backend::Netssh
+
+ # since the @pool is a class instance variable, we need to copy
+ # the code from the superclass that initializes it. boo
+ @pool = SSHKit::Backend::ConnectionPool.new
+
+ # modify to pass itself to the block, instead of relying on instance_exec.
+ def run
+ Thread.current["sshkit_backend"] = self
+ # was: instance_exec(@host, &@block)
+ @block.call(self, @host)
+ ensure
+ Thread.current["sshkit_backend"] = nil
+ end
+
+ # if set, all the commands will begin with:
+ # sudo -u #{@user} -- sh -c '<command>'
+ def set_user(user='root')
+ @user = user
+ end
+
+ #
+ # like default capture, but gracefully logs failures for us
+ # last argument can be an options hash.
+ #
+ # available options:
+ #
+ # :fail_msg - [nil] if set, log this instead of the default
+ # fail message.
+ #
+ # :raise_error - [nil] if true, then reraise failed command exception.
+ #
+ # :log_cmd - [false] if true, log what the command is that gets run.
+ #
+ # :log_output - [true] if true, log each output from the command as
+ # it is received.
+ #
+ # :log_finish - [false] if true, log the exit status and time
+ # to completion
+ #
+ # :log_wrap - [nil] passed to log method as :wrap option.
+ #
+ def capture(*args)
+ extract_options(args)
+ initialize_logger(:log_output => false)
+ rescue_ssh_errors(*args) do
+ return super(*args)
+ end
+ end
+
+ #
+ # like default execute, but log the results as they come in.
+ #
+ # see capture() for available options
+ #
+ def stream(*args)
+ extract_options(args)
+ initialize_logger
+ rescue_ssh_errors(*args) do
+ execute(*args)
+ end
+ end
+
+ def log(*args, &block)
+ @logger ||= LeapCli.new_logger
+ @logger.log(*args, &block)
+ end
+
+ # some prewritten servers-side scripts
+ def scripts
+ @scripts ||= LeapCli::SSH::Scripts.new(self, @host.hostname)
+ end
+
+ #
+ # sshkit just passes upload! and download! to Net::SCP, but Net::SCP
+ # make it impossible to set the file permissions. Here is how the mode
+ # is determined, from upload.rb:
+ #
+ # mode = channel[:stat] ? channel[:stat].mode & 07777 : channel[:options][:mode]
+ #
+ # The stat info from the file always overrides the mode you pass in options.
+ # However, the channel[:options][:mode] will be applied for pure in-memory
+ # uploads. So, if the mode is set, we convert the upload to be a memory
+ # upload instead of a file upload.
+ #
+ # Stupid, but blame Net::SCP.
+ #
+ def upload!(src, dest, options={})
+ if options[:mode]
+ if src.is_a?(StringIO)
+ content = src
+ else
+ content = StringIO.new(File.read(src))
+ end
+ super(content, dest, options)
+ else
+ super(src, dest, options)
+ end
+ end
+
+ private
+
+ #
+ # creates a new logger instance for this specific ssh command.
+ # by doing this, each ssh session has its own logger and its own
+ # indentation.
+ #
+ # potentially modifies 'args' array argument.
+ #
+ def initialize_logger(default_options={})
+ @logger ||= LeapCli.new_logger
+ @output = LeapCli::SSH::Formatter.new(@logger, @host, default_options.merge(@options))
+ end
+
+ def extract_options(args)
+ if args.last.is_a? Hash
+ @options = args.pop
+ else
+ @options = {}
+ end
+ end
+
+ #
+ # capture common exceptions
+ #
+ def rescue_ssh_errors(*args, &block)
+ yield
+ rescue Net::SSH::HostKeyMismatch => exc
+ @logger.log(:fatal_error, "Host key mismatch!") do
+ @logger.log(exc.to_s)
+ @logger.log("The ssh host key for the server does not match what is on "+
+ " file in `%s`." % Path.named_path(:known_hosts))
+ @logger.log("One of these is happening:") do
+ @logger.log("There is an active Man in The Middle attack against you.")
+ @logger.log("Or, someone has generated new host keys for the server " +
+ "and your provider files are out of date.")
+ @logger.log("Or, a new server is using this IP address " +
+ "and your provider files are out of date.")
+ @logger.log("Or, the server configuration has changed to use a different host key.")
+ end
+ @logger.log("You can pin a different host key using `leap node init NODE`, " +
+ "but you must verify the fingerprint of the new host key!")
+ end
+ exit(1)
+ rescue StandardError => exc
+ if exc.is_a?(SSHKit::Command::Failed) || exc.is_a?(SSHKit::Runner::ExecuteError)
+ if @options[:raise_error]
+ raise LeapCli::SSH::ExecuteError, exc.to_s
+ elsif @options[:fail_msg]
+ @logger.log(@options[:fail_msg], host: @host.hostname, :color => :red)
+ else
+ @logger.log(:failed, args.join(' '), host: @host.hostname) do
+ @logger.log(exc.to_s.strip, wrap: true)
+ end
+ end
+ elsif exc.is_a?(Timeout::Error) || exc.is_a?(Net::SSH::ConnectionTimeout)
+ @logger.log(:failed, args.join(' '), host: @host.hostname) do
+ @logger.log("Connection timed out")
+ end
+ if @options[:raise_error]
+ raise LeapCli::SSH::TimeoutError, exc.to_s
+ end
+ else
+ raise
+ end
+ return nil
+ end
+
+ def output
+ @output ||= LeapCli::SSH::Formatter.new(@logger, @host)
+ end
+
+ end
+ end
+end
+
diff --git a/lib/leap_cli/ssh/formatter.rb b/lib/leap_cli/ssh/formatter.rb
new file mode 100644
index 00000000..c2e386dc
--- /dev/null
+++ b/lib/leap_cli/ssh/formatter.rb
@@ -0,0 +1,70 @@
+#
+# A custom SSHKit formatter that uses LeapLogger.
+#
+
+require 'sshkit'
+
+module LeapCli
+ module SSH
+
+ class Formatter < SSHKit::Formatter::Abstract
+
+ DEFAULT_OPTIONS = {
+ :log_cmd => false, # log what the command is that gets run.
+ :log_output => true, # log each output from the command as it is received.
+ :log_finish => false # log the exit status and time to completion.
+ }
+
+ def initialize(logger, host, options={})
+ @logger = logger || LeapCli.new_logger
+ @host = host
+ @options = DEFAULT_OPTIONS.merge(options)
+ end
+
+ def write(obj)
+ @logger.log(obj.to_s, :host => @host.hostname)
+ end
+
+ def log_command_start(command)
+ if @options[:log_cmd]
+ @logger.log(:running, "`" + command.to_s + "`", :host => @host.hostname)
+ end
+ end
+
+ def log_command_data(command, stream_type, stream_data)
+ if @options[:log_output]
+ color = stream_type == :stderr ? :red : nil
+ @logger.log(stream_data.to_s.chomp,
+ :color => color, :host => @host.hostname, :wrap => options[:log_wrap])
+ end
+ end
+
+ def log_command_exit(command)
+ if @options[:log_finish]
+ runtime = sprintf('%5.3fs', command.runtime)
+ if command.failure?
+ message = "in #{runtime} with status #{command.exit_status}."
+ @logger.log(:failed, message, :host => @host.hostname)
+ else
+ message = "in #{runtime}."
+ @logger.log(:completed, message, :host => @host.hostname)
+ end
+ end
+ end
+ end
+
+ end
+end
+
+ #
+ # A custom InteractionHandler that will output the results as they come in.
+ #
+ #class LoggingInteractionHandler
+ # def initialize(hostname, logger=nil)
+ # @hostname = hostname
+ # @logger = logger || LeapCli.new_logger
+ # end
+ # def on_data(command, stream_name, data, channel)
+ # @logger.log(data, host: @hostname, wrap: true)
+ # end
+ #end
diff --git a/lib/leap_cli/ssh/key.rb b/lib/leap_cli/ssh/key.rb
new file mode 100644
index 00000000..76223b7e
--- /dev/null
+++ b/lib/leap_cli/ssh/key.rb
@@ -0,0 +1,310 @@
+#
+# A wrapper around OpenSSL::PKey::RSA instances to provide a better api for
+# dealing with SSH keys.
+#
+# NOTES:
+#
+# cipher 'ssh-ed25519' not supported yet because we are waiting
+# for support in Net::SSH
+#
+# there are many ways to represent an SSH key, since SSH keys can be of
+# a variety of types.
+#
+# To confuse matters more, there are multiple binary representations.
+# So, for example, an RSA key has a native SSH representation
+# (two bignums, e followed by n), and a DER representation.
+#
+# AWS uses fingerprints of the DER representation, but SSH typically reports
+# fingerprints of the SSH representation.
+#
+# Also, SSH public key files are base64 encoded, but with whitespace removed
+# so it all goes on one line.
+#
+# Some useful links:
+#
+# https://stackoverflow.com/questions/3162155/convert-rsa-public-key-to-rsa-der
+# https://net-ssh.github.io/ssh/v2/api/classes/Net/SSH/Buffer.html
+# https://serverfault.com/questions/603982/why-does-my-openssh-key-fingerprint-not-match-the-aws-ec2-console-keypair-finger
+#
+
+require 'net/ssh'
+require 'forwardable'
+require 'base64'
+
+module LeapCli
+ module SSH
+ class Key
+ extend Forwardable
+
+ attr_accessor :filename
+ attr_accessor :comment
+
+ # supported ssh key types, in order of preference
+ SUPPORTED_TYPES = ['ssh-rsa', 'ecdsa-sha2-nistp256']
+ SUPPORTED_TYPES_RE = /(#{SUPPORTED_TYPES.join('|')})/
+
+ ##
+ ## CLASS METHODS
+ ##
+
+ def self.load(arg1, arg2=nil)
+ key = nil
+ if arg1.is_a? OpenSSL::PKey::RSA
+ key = Key.new arg1
+ elsif arg1.is_a? String
+ if arg1 =~ /^ssh-/
+ type, data = arg1.split(' ')
+ key = Key.new load_from_data(data, type)
+ elsif File.exist? arg1
+ key = Key.new load_from_file(arg1)
+ key.filename = arg1
+ else
+ key = Key.new load_from_data(arg1, arg2)
+ end
+ end
+ return key
+ rescue StandardError
+ end
+
+ def self.load_from_file(filename)
+ public_key = nil
+ private_key = nil
+ begin
+ public_key = Net::SSH::KeyFactory.load_public_key(filename)
+ rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError
+ begin
+ private_key = Net::SSH::KeyFactory.load_private_key(filename)
+ rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError
+ end
+ end
+ public_key || private_key
+ end
+
+ def self.load_from_data(data, type='ssh-rsa')
+ public_key = nil
+ private_key = nil
+ begin
+ public_key = Net::SSH::KeyFactory.load_data_public_key("#{type} #{data}")
+ rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError
+ begin
+ private_key = Net::SSH::KeyFactory.load_data_private_key("#{type} #{data}")
+ rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError
+ end
+ end
+ public_key || private_key
+ end
+
+ def self.my_public_keys
+ load_keys_from_paths File.join(ENV['HOME'], '.ssh', '*.pub')
+ end
+
+ def self.provider_public_keys
+ load_keys_from_paths Path.named_path([:user_ssh, '*'])
+ end
+
+ #
+ # Picks one key out of an array of keys that we think is the "best",
+ # based on the order of preference in SUPPORTED_TYPES
+ #
+ # Currently, this does not take bitsize into account.
+ #
+ def self.pick_best_key(keys)
+ keys.select {|k|
+ SUPPORTED_TYPES.include?(k.type)
+ }.sort {|a,b|
+ SUPPORTED_TYPES.index(a.type) <=> SUPPORTED_TYPES.index(b.type)
+ }.first
+ end
+
+ #
+ # takes a string with one or more ssh keys, one key per line,
+ # and returns an array of Key objects.
+ #
+ # the lines should be in one of these formats:
+ #
+ # 1. <hostname> <key-type> <key>
+ # 2. <key-type> <key>
+ #
+ def self.parse_keys(string)
+ keys = []
+ lines = string.split("\n").grep(/^[^#]/)
+ lines.each do |line|
+ if line =~ / #{Key::SUPPORTED_TYPES_RE} /
+ # <hostname> <key-type> <key>
+ keys << line.split(' ')[1..2]
+ elsif line =~ /^#{Key::SUPPORTED_TYPES_RE} /
+ # <key-type> <key>
+ keys << line.split(' ')
+ end
+ end
+ return keys.map{|k| Key.load(k[1], k[0])}
+ end
+
+ #
+ # takes a string with one or more ssh keys, one key per line,
+ # and returns a string that specified the ssh key algorithms
+ # that are supported by the keys, in order of preference.
+ #
+ # eg: ecdsa-sha2-nistp256,ssh-rsa,ssh-ed25519
+ #
+ def self.supported_host_key_algorithms(string)
+ if string
+ self.parse_keys(string).map {|key|
+ key.type
+ }.join(',')
+ else
+ ""
+ end
+ end
+
+ private
+
+ def self.load_keys_from_paths(key_glob)
+ keys = []
+ Dir.glob(key_glob).each do |file|
+ key = Key.load(file)
+ if key && key.public?
+ keys << key
+ end
+ end
+ return keys
+ end
+
+ ##
+ ## INSTANCE METHODS
+ ##
+
+ public
+
+ def initialize(p_key)
+ @key = p_key
+ end
+
+ def_delegator :@key, :ssh_type, :type
+ def_delegator :@key, :public_encrypt, :public_encrypt
+ def_delegator :@key, :public_decrypt, :public_decrypt
+ def_delegator :@key, :private_encrypt, :private_encrypt
+ def_delegator :@key, :private_decrypt, :private_decrypt
+ def_delegator :@key, :params, :params
+ def_delegator :@key, :to_text, :to_text
+
+ def public_key
+ Key.new(@key.public_key)
+ end
+
+ def private_key
+ Key.new(@key.private_key)
+ end
+
+ def private?
+ @key.respond_to?(:private?) ? @key.private? : @key.private_key?
+ end
+
+ def public?
+ @key.respond_to?(:public?) ? @key.public? : @key.public_key?
+ end
+
+ #
+ # three arguments:
+ #
+ # - digest: one of md5, sha1, sha256, etc. (default sha256)
+ # - encoding: either :hex (default) or :base64
+ # - type: fingerprint type, either :ssh (default) or :der
+ #
+ # NOTE:
+ #
+ # * I am not sure how to make a fingerprint for OpenSSL::PKey::EC::Point
+ #
+ # * AWS reports fingerprints using MD5 digest for uploaded ssh keys,
+ # but SHA1 for keys it created itself.
+ #
+ # * Also, AWS fingerprints are digests on the DER encoding of the key.
+ # But standard SSH fingerprints are digests of SSH encoding of the key.
+ #
+ # * Other tools will sometimes display fingerprints in hex and sometimes
+ # in base64. Arrrgh.
+ #
+ def fingerprint(type: :ssh, digest: :sha256, encoding: :hex)
+ require 'digest'
+
+ digest = digest.to_s.upcase
+ digester = case digest
+ when "MD5" then Digest::MD5.new
+ when "SHA1" then Digest::SHA1.new
+ when "SHA256" then Digest::SHA256.new
+ when "SHA384" then Digest::SHA384.new
+ when "SHA512" then Digest::SHA512.new
+ else raise ArgumentError, "digest #{digest} is unknown"
+ end
+
+ keymatter = nil
+ if type == :der && @key.respond_to?(:to_der)
+ keymatter = @key.to_der
+ else
+ keymatter = self.raw_key.to_s
+ end
+
+ fp = nil
+ if encoding == :hex
+ fp = digester.hexdigest(keymatter)
+ elsif encoding == :base64
+ fp = Base64.encode64(digester.digest(keymatter)).sub(/=$/, '')
+ else
+ raise ArgumentError, "encoding #{encoding} not understood"
+ end
+
+ if digest == "MD5" && encoding == :hex
+ return fp.scan(/../).join(':')
+ else
+ return fp
+ end
+ end
+
+ #
+ # not sure if this will always work, but is seems to for now.
+ #
+ def bits
+ Net::SSH::Buffer.from(:key, @key).to_s.split("\001\000").last.size * 8
+ end
+
+ def summary
+ if self.filename
+ "%s %s %s (%s)" % [self.type, self.bits, self.fingerprint, File.basename(self.filename)]
+ else
+ "%s %s %s" % [self.type, self.bits, self.fingerprint]
+ end
+ end
+
+ def to_s
+ self.type + " " + self.key
+ end
+
+ #
+ # base64 encoding of the key, with spaces removed.
+ #
+ def key
+ [Net::SSH::Buffer.from(:key, @key).to_s].pack("m*").gsub(/\s/, "")
+ end
+
+ def raw_key
+ Net::SSH::Buffer.from(:key, @key)
+ end
+
+ def ==(other_key)
+ return false if other_key.nil?
+ return false if self.class != other_key.class
+ return self.to_text == other_key.to_text
+ end
+
+ def in_known_hosts?(*identifiers)
+ identifiers.each do |identifier|
+ Net::SSH::KnownHosts.search_for(identifier).each do |key|
+ return true if self == key
+ end
+ end
+ return false
+ end
+
+ end
+ end
+end
diff --git a/lib/leap_cli/ssh/options.rb b/lib/leap_cli/ssh/options.rb
new file mode 100644
index 00000000..7bc06564
--- /dev/null
+++ b/lib/leap_cli/ssh/options.rb
@@ -0,0 +1,100 @@
+#
+# Options for passing to the ruby gem ssh-net
+#
+
+module LeapCli
+ module SSH
+ module Options
+
+ #
+ # options passed to net-ssh. See
+ # https://net-ssh.github.io/net-ssh/Net/SSH.html#method-c-start
+ # for the available options.
+ #
+ def self.global_options
+ {
+ #:keys_only => true,
+ :global_known_hosts_file => Path.named_path(:known_hosts),
+ :user_known_hosts_file => '/dev/null',
+ :paranoid => true,
+ :verbose => net_ssh_log_level,
+ :auth_methods => ["publickey"],
+ :timeout => 5
+ }
+ end
+
+ def self.node_options(node, ssh_options_override=nil)
+ {
+ # :host_key_alias => node.name, << incompatible with ports in known_hosts
+ :host_name => node.ip_address,
+ :port => node.ssh.port
+ }.merge(
+ contingent_ssh_options_for_node(node)
+ ).merge(
+ ssh_options_override||{}
+ )
+ end
+
+ def self.options_from_args(args)
+ ssh_options = {}
+ if args[:port]
+ ssh_options[:port] = args[:port]
+ end
+ if args[:ip]
+ ssh_options[:host_name] = args[:ip]
+ end
+ if args[:auth_methods]
+ ssh_options[:auth_methods] = args[:auth_methods]
+ end
+ if args[:user]
+ ssh_options[:user] = args[:user]
+ end
+ return ssh_options
+ end
+
+ def self.sanitize_command(cmd)
+ if cmd =~ /(^|\/| )rm / || cmd =~ /(^|\/| )unlink /
+ LeapCli.log :warning, "You probably don't want to do that. Run with --force if you are really sure."
+ exit(1)
+ else
+ cmd
+ end
+ end
+
+ private
+
+ def self.contingent_ssh_options_for_node(node)
+ opts = {}
+ if node.vagrant?
+ opts[:keys] = [LeapCli::Util::Vagrant.vagrant_ssh_key_file]
+ opts[:keys_only] = true # only use the keys specified above, and
+ # ignore whatever keys the ssh-agent is aware of.
+ opts[:paranoid] = false # we skip host checking for vagrant nodes,
+ # because fingerprint is different for everyone.
+ if LeapCli.logger.log_level <= 1
+ opts[:verbose] = :error # suppress all the warnings about adding
+ # host keys to known_hosts, since it is
+ # not actually doing that.
+ end
+ end
+ if !node.supported_ssh_host_key_algorithms.empty?
+ opts[:host_key] = node.supported_ssh_host_key_algorithms
+ end
+ return opts
+ end
+
+ def self.net_ssh_log_level
+ if DEBUG
+ case LeapCli.logger.log_level
+ when 1 then :error
+ when 2 then :info
+ else :debug
+ end
+ else
+ :fatal
+ end
+ end
+
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/ssh/remote_command.rb b/lib/leap_cli/ssh/remote_command.rb
new file mode 100644
index 00000000..0e9f2d55
--- /dev/null
+++ b/lib/leap_cli/ssh/remote_command.rb
@@ -0,0 +1,124 @@
+#
+# Provides SSH.remote_command for running commands in parallel or in sequence
+# on remote servers.
+#
+# The gem sshkit is used for this.
+#
+
+require 'sshkit'
+require 'leap_cli/ssh/options'
+require 'leap_cli/ssh/backend'
+
+SSHKit.config.backend = LeapCli::SSH::Backend
+LeapCli::SSH::Backend.config.ssh_options = LeapCli::SSH::Options.global_options
+
+#
+# define remote_command
+#
+module LeapCli
+ module SSH
+
+ class ExecuteError < StandardError
+ end
+
+ class TimeoutError < ExecuteError
+ end
+
+ # override default runner mode
+ class CustomCoordinator < SSHKit::Coordinator
+ private
+ def default_options
+ { in: :groups, limit: 10, wait: 0 }
+ end
+ end
+
+ #
+ # Available options:
+ #
+ # :port -- ssh port
+ # :ip -- ssh ip
+ # :auth_methods -- e.g. ["pubkey", "password"]
+ # :user -- default 'root'
+ #
+ def self.remote_command(nodes, options={}, &block)
+ CustomCoordinator.new(
+ host_list(
+ nodes,
+ SSH::Options.options_from_args(options)
+ )
+ ).each do |ssh, host|
+ LeapCli.log 2, "ssh options for #{host.hostname}: #{host.ssh_options.inspect}"
+ if host.user != 'root'
+ # if the ssh user is not root, we want to make the ssh commands
+ # switch to root before they are run:
+ ssh.set_user('root')
+ end
+ yield ssh, host
+ end
+ end
+
+ #
+ # For example:
+ #
+ # SSH.remote_sync(nodes) do |sync, host|
+ # sync.source = '/from'
+ # sync.dest = '/to'
+ # sync.flags = ''
+ # sync.includes = []
+ # sync.excludes = []
+ # sync.exec
+ # end
+ #
+ def self.remote_sync(nodes, options={}, &block)
+ require 'rsync_command'
+ hosts = host_list(
+ nodes,
+ SSH::Options.options_from_args(options)
+ )
+ rsync = RsyncCommand.new(:logger => LeapCli::logger)
+ rsync.asynchronously(hosts) do |sync, host|
+ sync.logger = LeapCli.new_logger
+ sync.user = host.user || fetch(:user, ENV['USER'])
+ sync.host = host.hostname
+ sync.ssh = SSH::Options.global_options.merge(host.ssh_options)
+ sync.chdir = Path.provider
+ yield(sync, host)
+ end
+ if rsync.failed?
+ LeapCli::Util.bail! do
+ LeapCli.log :failed, "to rsync to #{rsync.failures.map{|f|f[:dest][:host]}.join(' ')}"
+ end
+ end
+ end
+
+ private
+
+ def self.host_list(nodes, ssh_options_override={})
+ if nodes.is_a?(Config::ObjectList)
+ list = nodes.values
+ elsif nodes.is_a?(Config::Node)
+ list = [nodes]
+ else
+ raise ArgumentError, "I don't understand the type of argument `nodes`"
+ end
+ list.collect do |node|
+ options = SSH::Options.node_options(node, ssh_options_override)
+ user = options.delete(:user) || 'root'
+ #
+ # note: whatever hostname is specified here will be what is used
+ # when loading options from .ssh/config. However, this value
+ # has no impact on the actual ip address that is connected to,
+ # which is determined by the :host_name value in ssh_options.
+ #
+ SSHKit::Host.new(
+ :hostname => node.domain.full,
+ :user => user,
+ :ssh_options => options
+ )
+ end
+ end
+
+ end
+end
+
+
diff --git a/lib/leap_cli/ssh/scripts.rb b/lib/leap_cli/ssh/scripts.rb
new file mode 100644
index 00000000..3dd6b604
--- /dev/null
+++ b/lib/leap_cli/ssh/scripts.rb
@@ -0,0 +1,163 @@
+#
+# Common commands that we would like to run on remote servers.
+#
+# These scripts are available via:
+#
+# SSH.remote_command(nodes) do |ssh, host|
+# ssh.script.custom_script_name
+# end
+#
+
+module LeapCli
+ module SSH
+ class Scripts
+
+ REQUIRED_PACKAGES = "puppet rsync lsb-release locales"
+
+ attr_reader :ssh, :host
+ def initialize(backend, hostname)
+ @ssh = backend
+ @host = hostname
+ end
+
+ #
+ # creates directories that are owned by root and 700 permissions
+ #
+ def mkdirs(*dirs)
+ raise ArgumentError.new('illegal dir name') if dirs.grep(/[\' ]/).any?
+ ssh.stream dirs.collect{|dir| "mkdir -m 700 -p #{dir}; "}.join
+ end
+
+ #
+ # echos "ok" if the node has been initialized and the required packages are installed, bails out otherwise.
+ #
+ def assert_initialized
+ begin
+ test_initialized_file = "test -f #{Leap::Platform.init_path}"
+ check_required_packages = "! dpkg-query -W --showformat='${Status}\n' #{REQUIRED_PACKAGES} 2>&1 | grep -q -E '(deinstall|no packages)'"
+ ssh.stream "#{test_initialized_file} && #{check_required_packages} && echo ok", :raise_error => true
+ rescue SSH::ExecuteError
+ ssh.log :error, "running deploy: node not initialized. Run `leap node init #{host}`.", :host => host
+ raise # will skip further action on this node
+ end
+ end
+
+ #
+ # bails out the deploy if the file /etc/leap/no-deploy exists.
+ #
+ def check_for_no_deploy
+ begin
+ ssh.stream "test ! -f /etc/leap/no-deploy", :raise_error => true, :log_output => false
+ rescue SSH::TimeoutError
+ raise
+ rescue SSH::ExecuteError
+ ssh.log :warning, "can't continue because file /etc/leap/no-deploy exists", :host => host
+ raise # will skip further action on this node
+ end
+ end
+
+ #
+ # dumps debugging information
+ #
+ def debug
+ output = ssh.capture "#{Leap::Platform.leap_dir}/bin/debug.sh"
+ ssh.log(output, :wrap => true, :host => host, :color => :cyan)
+ end
+
+ #
+ # dumps the recent deploy history to the console
+ #
+ def history(lines)
+ cmd = "(test -s /var/log/leap/deploy-summary.log && tail -n #{lines} /var/log/leap/deploy-summary.log) || (test -s /var/log/leap/deploy-summary.log.1 && tail -n #{lines} /var/log/leap/deploy-summary.log.1) || (echo 'no history')"
+ history = ssh.capture(cmd, :log_output => false)
+ if history
+ ssh.log host, :color => :cyan, :style => :bold do
+ ssh.log history, :wrap => true
+ end
+ end
+ end
+
+ #
+ # apply puppet! weeeeeee
+ #
+ def puppet_apply(options)
+ cmd = "#{Leap::Platform.leap_dir}/bin/puppet_command set_hostname apply #{flagize(options)}"
+ ssh.stream cmd, :log_finish => true
+ end
+
+ def install_authorized_keys
+ ssh.log :updating, "authorized_keys" do
+ mkdirs '/root/.ssh'
+ ssh.upload! LeapCli::Path.named_path(:authorized_keys), '/root/.ssh/authorized_keys', :mode => 0600
+ end
+ end
+
+ #
+ # for vagrant nodes, we install insecure vagrant key to authorized_keys2, since deploy
+ # will overwrite authorized_keys.
+ #
+ # why force the insecure vagrant key?
+ # if we don't do this, then first time initialization might fail if the user has many keys
+ # (ssh will bomb out before it gets to the vagrant key).
+ # and it really doesn't make sense to ask users to pin the insecure vagrant key in their
+ # .ssh/config files.
+ #
+ def install_insecure_vagrant_key
+ ssh.log :installing, "insecure vagrant key" do
+ mkdirs '/root/.ssh'
+ ssh.upload! LeapCli::Path.vagrant_ssh_pub_key_file, '/root/.ssh/authorized_keys2', :mode => 0600
+ end
+ end
+
+ def install_prerequisites
+ bin_dir = File.join(Leap::Platform.leap_dir, 'bin')
+ node_init_path = File.join(bin_dir, 'node_init')
+ ssh.log :running, "node_init script" do
+ mkdirs bin_dir
+ ssh.upload! LeapCli::Path.node_init_script, node_init_path, :mode => 0700
+ ssh.stream node_init_path, :log_wrap => true
+ end
+ end
+
+ #
+ # AWS debian images only allow you to login as admin. This is done with a
+ # custom command in /root/.ssh/authorized_keys, instead of by modifying
+ # /etc/ssh/sshd_config.
+ #
+ # We need to be able to ssh as root for scp and rsync to work.
+ #
+ # This command is run as 'admin', with a sudo wrapper. In order for the
+ # sudo to work, the command must be specified as separate arguments with
+ # no spaces (that is how ssh-kit works).
+ #
+ def allow_root_ssh
+ ssh.execute 'cp', '/home/admin/.ssh/authorized_keys', '/root/.ssh/authorized_keys'
+ end
+
+ #
+ # uploads an acme challenge for renewing certificates using Let's Encrypt CA.
+ #
+ # Filename is returned from acme api, so it must not be trusted.
+ #
+ def upload_acme_challenge(filename, content)
+ path = '/srv/acme/' + filename.gsub(/[^a-zA-Z0-9_-]/, '')
+ ssh.upload! StringIO.new(content), path, :mode => 0444
+ end
+
+ private
+
+ def flagize(hsh)
+ hsh.inject([]) {|str, item|
+ if item[1] === false
+ str
+ elsif item[1] === true
+ str << "--" + item[0].to_s
+ else
+ str << "--" + item[0].to_s + " " + item[1].inspect
+ end
+ }.join(' ')
+ end
+
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/util/console_table.rb b/lib/leap_cli/util/console_table.rb
new file mode 100644
index 00000000..ccdcc2ab
--- /dev/null
+++ b/lib/leap_cli/util/console_table.rb
@@ -0,0 +1,62 @@
+module LeapCli; module Util
+
+ class ConsoleTable
+ def table
+ @rows = []
+ @cell_options = []
+
+ @row_options = []
+ @column_widths = []
+ @column_options = []
+
+ @current_row = 0
+ @current_column = 0
+ yield
+ end
+
+ def row(options=nil)
+ @current_column = 0
+ @rows[@current_row] = []
+ @cell_options[@current_row] = []
+ @row_options[@current_row] ||= options
+ yield
+ @current_row += 1
+ end
+
+ def column(str, options={})
+ str ||= ""
+ @rows[@current_row][@current_column] = str
+ @cell_options[@current_row][@current_column] = options
+ @column_widths[@current_column] = [str.length, options[:min_width]||0, @column_widths[@current_column]||0].max
+ @column_options[@current_column] ||= options
+ @current_column += 1
+ end
+
+ def draw_table
+ @rows.each_with_index do |row, i|
+ color = (@row_options[i]||{})[:color]
+ row.each_with_index do |column, j|
+ align = (@column_options[j]||{})[:align] || "left"
+ width = @column_widths[j]
+ cell_color = @cell_options[i][j] && @cell_options[i][j][:color]
+ cell_color ||= color
+ if cell_color
+ str = LeapCli.logger.colorize(column, cell_color)
+ extra_width = str.length - column.length
+ else
+ str = column
+ extra_width = 0
+ end
+ if align == "right"
+ printf " %#{width+extra_width}s" % str
+ else
+ printf " %-#{width+extra_width}s" % str
+ end
+ end
+ puts
+ end
+ puts
+ end
+ end
+
+end; end \ No newline at end of file
diff --git a/lib/leap_cli/util/secret.rb b/lib/leap_cli/util/secret.rb
new file mode 100644
index 00000000..749b9595
--- /dev/null
+++ b/lib/leap_cli/util/secret.rb
@@ -0,0 +1,55 @@
+# encoding: utf-8
+#
+# A simple secret generator
+#
+# Uses OpenSSL random number generator instead of Ruby's rand function
+#
+autoload :OpenSSL, 'openssl'
+
+module LeapCli; module Util
+ class Secret
+ CHARS = (('A'..'Z').to_a + ('a'..'z').to_a + ('0'..'9').to_a) - "i1loO06G".split(//u)
+ HEX = (0..9).to_a + ('a'..'f').to_a
+
+ #
+ # generate a secret with with no ambiguous characters.
+ #
+ # +length+ is in chars
+ #
+ # Only alphanumerics are allowed, in order to make these passwords work
+ # for REST url calls and to allow you to easily copy and paste them.
+ #
+ def self.generate(length = 16)
+ seed
+ OpenSSL::Random.random_bytes(length).bytes.to_a.collect { |byte|
+ CHARS[ byte % CHARS.length ]
+ }.join
+ end
+
+ #
+ # generates a hex secret, instead of an alphanumeric on.
+ #
+ # length is in bits
+ #
+ def self.generate_hex(length = 128)
+ seed
+ OpenSSL::Random.random_bytes(length/4).bytes.to_a.collect { |byte|
+ HEX[ byte % HEX.length ]
+ }.join
+ end
+
+ private
+
+ def self.seed
+ @pid ||= 0
+ pid = $$
+ if @pid != pid
+ now = Time.now
+ ary = [now.to_i, now.nsec, @pid, pid]
+ OpenSSL::Random.seed(ary.to_s)
+ @pid = pid
+ end
+ end
+
+ end
+end; end
diff --git a/lib/leap_cli/util/vagrant.rb b/lib/leap_cli/util/vagrant.rb
new file mode 100644
index 00000000..c67ea4f1
--- /dev/null
+++ b/lib/leap_cli/util/vagrant.rb
@@ -0,0 +1,26 @@
+require 'fileutils'
+
+module LeapCli
+ module Util
+ module Vagrant
+
+ #
+ # 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 self.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
+
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/x509.rb b/lib/leap_cli/x509.rb
new file mode 100644
index 00000000..68d13ddf
--- /dev/null
+++ b/lib/leap_cli/x509.rb
@@ -0,0 +1,16 @@
+#
+# optional. load if you want access to any methods in the module X509
+#
+
+require 'date'
+require 'securerandom'
+require 'openssl'
+require 'digest'
+require 'digest/md5'
+require 'digest/sha1'
+
+require 'certificate_authority'
+
+require 'leap_cli/x509/certs'
+require 'leap_cli/x509/signing_profiles'
+require 'leap_cli/x509/utils'
diff --git a/lib/leap_cli/x509/certs.rb b/lib/leap_cli/x509/certs.rb
new file mode 100644
index 00000000..3b74d2fb
--- /dev/null
+++ b/lib/leap_cli/x509/certs.rb
@@ -0,0 +1,232 @@
+
+module LeapCli; module X509
+
+ #
+ # returns a fingerprint of a x509 certificate
+ #
+ # Note: there are different ways of computing a digest of a certificate.
+ # You can either take a digest of the entire cert in DER format, or you
+ # can take a digest of the public key.
+ #
+ # For now, we only support the DER method.
+ #
+ def self.fingerprint(digest, cert_file)
+ if cert_file.is_a? String
+ cert = OpenSSL::X509::Certificate.new(Util.read_file!(cert_file))
+ elsif cert_file.is_a? OpenSSL::X509::Certificate
+ cert = cert_file
+ elsif cert_file.is_a? CertificateAuthority::Certificate
+ cert = cert_file.openssl_body
+ end
+ digester = case digest
+ when "MD5" then Digest::MD5.new
+ when "SHA1" then Digest::SHA1.new
+ when "SHA256" then Digest::SHA256.new
+ when "SHA384" then Digest::SHA384.new
+ when "SHA512" then Digest::SHA512.new
+ end
+ digester.hexdigest(cert.to_der)
+ end
+
+ def self.ca_root
+ @ca_root ||= begin
+ load_certificate_file(:ca_cert, :ca_key)
+ end
+ end
+
+ def self.client_ca_root
+ @client_ca_root ||= begin
+ load_certificate_file(:client_ca_cert, :client_ca_key)
+ end
+ end
+
+ def self.load_certificate_file(crt_file, key_file=nil, password=nil)
+ crt = Util.read_file!(crt_file)
+ openssl_cert = OpenSSL::X509::Certificate.new(crt)
+ cert = CertificateAuthority::Certificate.from_openssl(openssl_cert)
+ if key_file
+ key = Util.read_file!(key_file)
+ cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, password)
+ end
+ return cert
+ end
+
+ #
+ # creates a new certificate authority.
+ #
+ def self.new_ca(options, common_name)
+ root = CertificateAuthority::Certificate.new
+
+ # set subject
+ root.subject.common_name = common_name
+ possible = ['country', 'state', 'locality', 'organization', 'organizational_unit', 'email_address']
+ options.keys.each do |key|
+ if possible.include?(key)
+ root.subject.send(key + '=', options[key])
+ end
+ end
+
+ # set expiration
+ root.not_before = X509.yesterday
+ root.not_after = X509.yesterday_advance(options['life_span'])
+
+ # generate private key
+ root.serial_number.number = 1
+ root.key_material.generate_key(options['bit_size'])
+
+ # sign self
+ root.signing_entity = true
+ root.parent = root
+ root.sign!(ca_root_signing_profile)
+ return root
+ end
+
+ #
+ # creates a CSR in memory and returns it.
+ # with the correct extReq attribute so that the CA
+ # doens't generate certs with extensions we don't want.
+ #
+ def self.new_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
+
+ #
+ # creates new csr and cert files for a particular domain.
+ #
+ # The cert is signed with the ca_root, but should be replaced
+ # later with a real cert signed by a better ca
+ #
+ def self.create_csr_and_cert(options)
+ bit_size = options[:bits].to_i
+ digest = options[:digest]
+
+ # RSA key
+ keypair = CertificateAuthority::MemoryKeyMaterial.new
+ Util.log :generating, "%s bit RSA key" % bit_size do
+ keypair.generate_key(bit_size)
+ Util.write_file! [:commercial_key, options[:domain]], keypair.private_key.to_pem
+ end
+
+ # CSR
+ csr = nil
+ dn = CertificateAuthority::DistinguishedName.new
+ dn.common_name = options[:domain]
+ dn.organization = options[:organization]
+ dn.ou = options[:organizational_unit]
+ dn.email_address = options[:email]
+ dn.country = options[:country]
+ dn.state = options[:state]
+ dn.locality = options[:locality]
+ Util.log :generating, "CSR with #{digest} digest and #{print_dn(dn)}" do
+ csr = new_csr(dn, keypair, options[:digest])
+ Util.write_file! [:commercial_csr, options[:domain]], csr.to_pem
+ 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.
+ Util.log :generating, "self-signed x509 server certificate for testing purposes" do
+ cert = csr.to_cert
+ cert.serial_number.number = cert_serial_number(options[:domain])
+ cert.not_before = yesterday
+ cert.not_after = yesterday.advance(:years => 1)
+ cert.parent = ca_root
+ cert.sign! domain_test_signing_profile
+ Util.write_file! [:commercial_cert, options[:domain]], cert.to_pem
+ Util.log "please replace this file with the real certificate you get from a CA using #{Path.relative_path([:commercial_csr, options[:domain]])}"
+ end
+
+ # Fake CA
+ unless Util.file_exists? :commercial_ca_cert
+ Util.log :using, "generated CA in place of commercial CA for testing purposes" do
+ Util.write_file! :commercial_ca_cert, Util.read_file!(:ca_cert)
+ Util.log "please also replace this file with the CA cert from the commercial authority you use."
+ end
+ end
+ end
+
+ #
+ # Return true if the given server cert has been signed by the given CA cert
+ #
+ # This does not actually validate the signature, it just checks the cert
+ # extensions.
+ #
+ def self.created_by_authority?(cert, ca=X509.ca_root)
+ authority_key_id = cert.extensions["authorityKeyIdentifier"].identifier.sub(/^keyid:/, '')
+ return authority_key_id == self.public_key_id_for_ca(ca)
+ 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)
+ #
+ def self.cert_serial_number(domain_name)
+ Digest::MD5.hexdigest("#{domain_name} -- #{Time.now}").to_i(16)
+ end
+
+ #
+ # for the random common name, we need a text string that will be
+ # unique across all certs.
+ #
+ def self.random_common_name(domain_name)
+ #cert_serial_number(domain_name).to_s(36)
+ SecureRandom.uuid
+ end
+
+ private
+
+ #
+ # calculate the "key id" for a root CA, that matches the value
+ # Authority Key Identifier in the x509 extensions of a cert.
+ #
+ def self.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(':')
+ end
+ end
+
+ # prints CertificateAuthority::DistinguishedName fields
+ def self.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
+
+end; end
diff --git a/lib/leap_cli/x509/signing_profiles.rb b/lib/leap_cli/x509/signing_profiles.rb
new file mode 100644
index 00000000..56cd29c7
--- /dev/null
+++ b/lib/leap_cli/x509/signing_profiles.rb
@@ -0,0 +1,104 @@
+#
+# Signing profiles are used by CertificateAuthority in order to
+# set the correct flags when signing certificates.
+#
+
+module LeapCli; module X509
+
+ #
+ # For CA self-signing
+ #
+ def self.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 self.server_signing_profile(node)
+ {
+ "digest" => node.env.provider.ca.server_certificates.digest,
+ "extensions" => {
+ "keyUsage" => {
+ "usage" => ["digitalSignature", "keyEncipherment"]
+ },
+ "extendedKeyUsage" => {
+ "usage" => ["serverAuth", "clientAuth"]
+ },
+ "subjectAltName" => {
+ "ips" => [node.ip_address],
+ "dns_names" => node.all_dns_names
+ }
+ }
+ }
+ 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.
+ #
+ def self.domain_test_signing_profile
+ {
+ "digest" => "SHA256",
+ "extensions" => {
+ "keyUsage" => {
+ "usage" => ["digitalSignature", "keyEncipherment"]
+ },
+ "extendedKeyUsage" => {
+ "usage" => ["serverAuth"]
+ }
+ }
+ }
+ end
+
+ #
+ # This is used when signing a dummy client certificate that is only to be
+ # used for testing.
+ #
+ def self.client_test_signing_profile
+ {
+ "digest" => "SHA256",
+ "extensions" => {
+ "keyUsage" => {
+ "usage" => ["digitalSignature"]
+ },
+ "extendedKeyUsage" => {
+ "usage" => ["clientAuth"]
+ }
+ }
+ }
+ end
+
+end; end \ No newline at end of file
diff --git a/lib/leap_cli/x509/utils.rb b/lib/leap_cli/x509/utils.rb
new file mode 100644
index 00000000..98ff9c0b
--- /dev/null
+++ b/lib/leap_cli/x509/utils.rb
@@ -0,0 +1,26 @@
+module LeapCli; module X509
+
+ #
+ # TIME HELPERS
+ #
+ # note: we use 'yesterday' instead of 'today', because times are in UTC, and
+ # some people on the planet are behind UTC!
+ #
+
+ def self.yesterday
+ t = Time.now - 24*24*60
+ Time.utc t.year, t.month, t.day
+ end
+
+ def self.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.")
+ end
+ yesterday.advance(unit.to_sym => number.to_i)
+ end
+
+end; end \ No newline at end of file