diff options
author | Micah Anderson <micah@riseup.net> | 2016-11-04 10:54:28 -0400 |
---|---|---|
committer | Micah Anderson <micah@riseup.net> | 2016-11-04 10:54:28 -0400 |
commit | 34a381efa8f6295080c843f86bfa07d4e41056af (patch) | |
tree | 9282cf5d4c876688602705a7fa0002bc4a810bde /lib | |
parent | 0a72bc6fd292bf9367b314fcb0347c4d35042f16 (diff) | |
parent | 5821964ff7e16ca7aa9141bd09a77d355db492a9 (diff) |
Merge branch 'develop'
Diffstat (limited to 'lib')
57 files changed, 5813 insertions, 917 deletions
diff --git a/lib/leap/platform.rb b/lib/leap/platform.rb new file mode 100644 index 00000000..9e6cadd5 --- /dev/null +++ b/lib/leap/platform.rb @@ -0,0 +1,99 @@ +module Leap + + class Platform + class << self + # + # configuration + # + + attr_reader :version + attr_reader :compatible_cli + attr_accessor :facts + attr_accessor :paths + attr_accessor :node_files + attr_accessor :monitor_username + attr_accessor :reserved_usernames + + attr_accessor :hiera_dir + attr_accessor :hiera_path + attr_accessor :files_dir + attr_accessor :leap_dir + attr_accessor :init_path + + attr_accessor :default_puppet_tags + + def define(&block) + # some defaults: + @reserved_usernames = [] + @hiera_dir = '/etc/leap' + @hiera_path = '/etc/leap/hiera.yaml' + @leap_dir = '/srv/leap' + @files_dir = '/srv/leap/files' + @init_path = '/srv/leap/initialized' + @default_puppet_tags = [] + + self.instance_eval(&block) + + @version ||= Gem::Version.new("0.0") + end + + def validate!(cli_version, compatible_platforms, leapfile) + if !compatible_with_cli?(cli_version) || !version_in_range?(compatible_platforms) + raise StandardError, "This leap command (v#{cli_version}) " + + "is not compatible with the platform #{leapfile.platform_directory_path} (v#{version}).\n " + + "You need either leap command #{compatible_cli.first} to #{compatible_cli.last} or " + + "platform version #{compatible_platforms.first} to #{compatible_platforms.last}" + end + end + + def version=(version) + @version = Gem::Version.new(version) + end + + def compatible_cli=(range) + @compatible_cli = range + @minimum_cli_version = Gem::Version.new(range.first) + @maximum_cli_version = Gem::Version.new(range.last) + end + + # + # return true if the cli_version is compatible with this platform. + # + def compatible_with_cli?(cli_version) + cli_version = Gem::Version.new(cli_version) + cli_version >= @minimum_cli_version && cli_version <= @maximum_cli_version + end + + # + # return true if the platform version is within the specified range. + # + def version_in_range?(range) + if range.is_a? String + range = range.split('..') + end + minimum_platform_version = Gem::Version.new(range.first) + maximum_platform_version = Gem::Version.new(range.last) + @version >= minimum_platform_version && @version <= maximum_platform_version + end + + def major_version + if @version.segments.first == 0 + @version.segments[0..1].join('.') + else + @version.segments.first + end + end + + def method_missing(method, *args) + puts + puts "WARNING:" + puts " leap_cli is out of date and does not understand `#{method}`." + puts " called from: #{caller.first}" + puts " please upgrade to a newer leap_cli" + end + + end + + end + +end
\ No newline at end of file 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 |