]> gitweb.fluxo.info Git - leap/leap_cli.git/commitdiff
x.509 support -- added certificate authority creation and server cert creation
authorelijah <elijah@riseup.net>
Thu, 1 Nov 2012 08:07:27 +0000 (01:07 -0700)
committerelijah <elijah@riseup.net>
Thu, 1 Nov 2012 08:07:27 +0000 (01:07 -0700)
13 files changed:
bin/leap
leap_cli.gemspec
lib/leap_cli/commands/ca.rb [new file with mode: 0644]
lib/leap_cli/commands/node.rb
lib/leap_cli/commands/project.rb
lib/leap_cli/config/manager.rb
lib/leap_cli/config/object.rb
lib/leap_cli/path.rb
lib/leap_cli/util.rb
lib/leap_cli/version.rb
test/provider/common.json
test/provider/provider.json
test/provider/services/openvpn.json

index dd6d9de741bc5cbcaba00935e52405fd3e0728c2..582d313f962f92616977c5e9d4f749d5f25181ba 100755 (executable)
--- a/bin/leap
+++ b/bin/leap
@@ -46,6 +46,7 @@ module LeapCli::Commands
   def_delegator :@terminal, :agree, 'self.agree'
   def_delegator :@terminal, :choose, 'self.choose'
   def_delegator :@terminal, :say, 'self.say'
+  def_delegator :@terminal, :color, 'self.color'
 
   #
   # make config manager available as 'manager'
@@ -61,8 +62,8 @@ module LeapCli::Commands
   #
   # info about leap command line suite
   #
-  program_desc       'LEAP platform command line interface'
-  program_long_desc  'This is the long description. It is very interesting.'
+  program_desc       LeapCli::SUMMARY
+  program_long_desc  LeapCli::DESCRIPTION
   version            LeapCli::VERSION
 
   #
index ecabe451a23a4ff83561cb794a7ca74643e24b5a..20e50a8512fbc6c220159cc19b850624a3861a0e 100644 (file)
@@ -16,6 +16,7 @@ spec = Gem::Specification.new do |s|
   s.platform = Gem::Platform::RUBY
   s.summary = LeapCli::SUMMARY
   s.description = LeapCli::DESCRIPTION
+  s.license = "GPLv3"
 
   ##
   ## GEM FILES
@@ -48,13 +49,16 @@ spec = Gem::Specification.new do |s|
   s.add_runtime_dependency('highline')
 
   # network gems
-  s.add_runtime_dependency('net-ssh')
   s.add_runtime_dependency('capistrano')
   #s.add_runtime_dependency('supply_drop')
 
+  # crypto gems
+  s.add_runtime_dependency('certificate_authority') # this gem pulls in ActiveModel, but it just uses it for validation logic.
+  s.add_runtime_dependency('net-ssh')
+  s.add_runtime_dependency('gpgme')     # not essential, but used for some minor stuff in adding sysadmins
+
   # misc gems
   s.add_runtime_dependency('ya2yaml')   # pure ruby yaml, so we can better control output. see https://github.com/afunai/ya2yaml
   s.add_runtime_dependency('json_pure') # pure ruby json, so we can better control output.
-  s.add_runtime_dependency('gpgme')     # not essential, but used for some minor stuff in adding sysadmins
 
 end
diff --git a/lib/leap_cli/commands/ca.rb b/lib/leap_cli/commands/ca.rb
new file mode 100644 (file)
index 0000000..9f1d42e
--- /dev/null
@@ -0,0 +1,162 @@
+require 'openssl'
+require 'certificate_authority'
+require 'date'
+require 'digest/md5'
+
+module LeapCli; module Commands
+
+  desc 'Creates the public and private key for your Certificate Authority.'
+  command :'init-ca' do |c|
+    c.action do |global_options,options,args|
+      assert_files_missing! :ca_cert, :ca_key
+      assert_config! 'provider.ca.name'
+      assert_config! 'provider.ca.bit_size'
+
+      provider = manager.provider
+      root = CertificateAuthority::Certificate.new
+
+      # set subject
+      root.subject.common_name = provider.ca.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])
+        end
+      end
+
+      # set expiration
+      years = 2
+      today = Date.today
+      root.not_before = Time.gm today.year, today.month, today.day
+      root.not_after = root.not_before + years * 60 * 60 * 24 * 365
+
+      # generate private key
+      root.serial_number.number = 1
+      root.key_material.generate_key(provider.ca.bit_size)
+
+      # sign self
+      root.signing_entity = true
+      root.parent = root
+      root.sign!(ca_root_signing_profile)
+
+      # save
+      write_file!(:ca_key, root.key_material.private_key.to_pem)
+      write_file!(:ca_cert, root.to_pem)
+    end
+  end
+
+  desc 'Creates or renews a X.509 certificate/key pair for a single node or all nodes'
+  arg_name '<node-name | "all">', :optional => false, :multiple => false
+  command :'update-cert' do |c|
+    c.action do |global_options,options,args|
+      assert_files_exist! :ca_cert, :ca_key, :msg => 'Run init-ca to create them'
+      assert_config! 'provider.ca.server_certificates.bit_size'
+      assert_config! 'provider.ca.server_certificates.life_span'
+
+      if args.first == 'all'
+        bail! 'not supported yet'
+      else
+        provider = manager.provider
+        ca_root  = cert_from_files(:ca_cert, :ca_key)
+        node     = get_node_from_args(args)
+
+        # set subject
+        cert = CertificateAuthority::Certificate.new
+        cert.subject.common_name = node.domain.full
+
+        # set expiration
+        years = provider.ca.server_certificates.life_span.to_i
+        today = Date.today
+        cert.not_before = Time.gm today.year, today.month, today.day
+        cert.not_after = cert.not_before + years * 60 * 60 * 24 * 365
+
+        # generate key
+        cert.serial_number.number = cert_serial_number(node.domain.full)
+        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
+    end
+  end
+
+  desc 'Generates Diffie-Hellman parameter file (needed for server-side of TLS connections)'
+  command :'init-dh' do |c|
+    c.action do |global_options,options,args|
+      long_running do
+        if cmd_exists?('certtool')
+          progress('Generating DH parameters (takes a long time)...')
+          output = assert_run!('certtool --generate-dh-params --sec-param high')
+          write_file!(:dh_params, output)
+        else
+          progress('Generating DH parameters (takes a REALLY long time)...')
+          output = OpenSSL::PKey::DH.generate(3248).to_pem
+          write_file!(:dh_params, output)
+        end
+      end
+    end
+  end
+
+  private
+
+  def cert_from_files(crt, key)
+    crt = read_file!(crt)
+    key = read_file!(key)
+    openssl_cert = OpenSSL::X509::Certificate.new(crt)
+    cert = CertificateAuthority::Certificate.from_openssl(openssl_cert)
+    cert.key_material.private_key = OpenSSL::PKey::RSA.new(key)  # second argument is password, if set
+    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. I am not sure which is preferable.
+  # going with keyAgreement for now.
+  #
+  def server_signing_profile(node)
+    {
+      "extensions" => {
+        "keyUsage" => {
+          "usage" => ["digitalSignature", "keyAgreement"]
+        },
+        "extendedKeyUsage" => {
+          "usage" => ["serverAuth"]
+        },
+        "subjectAltName" => {
+          "uris" => [
+            "IP:#{node.ip_address}",
+            "DNS:#{node.domain.internal}"
+          ]
+        }
+      }
+    }
+  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 cert_serial_number(domain_name)
+    Digest::MD5.hexdigest("#{domain_name} -- #{Time.now}").to_i(16)
+  end
+
+end; end
index e96293c8cee953a7faa56aa1e22c8cfde876ae14..e200a1920b2ed30505a0a813042198bf06a06e8e 100644 (file)
@@ -8,7 +8,7 @@ module LeapCli; module Commands
   ##
 
   desc 'not yet implemented... Create a new configuration for a node'
-  command :'new-node' do |c|
+  command :'add-node' do |c|
     c.action do |global_options,options,args|
     end
   end
index 8ec96257cec562ce2d776448dcf194372f38e547..c7481286f0683fcd24c7fc8f9eb8ad2ae58ad316 100644 (file)
@@ -4,7 +4,7 @@ module LeapCli
     desc 'Creates a new provider directory.'
     arg_name '<directory>'
     skips_pre
-    command :'new-provider' do |c|
+    command :'init-provider' do |c|
       c.action do |global_options,options,args|
         directory = args.first
         unless directory && directory.any?
index 246b79f7ab69178e2bb00d9520cced75441961b1..72958ddca73eed8ebe87ba0e69e6afcf38a81749 100644 (file)
@@ -8,7 +8,7 @@ module LeapCli
     #
     class Manager
 
-      attr_reader :services, :tags, :nodes, :provider
+      attr_reader :services, :tags, :nodes, :provider, :common
 
       ##
       ## IMPORT EXPORT
@@ -18,11 +18,11 @@ module LeapCli
       # load .json configuration files
       #
       def load(provider_dir=Path.provider)
-        @services = load_all_json(Path.named_path( [:service_config, '*'], provider_dir ))
-        @tags     = load_all_json(Path.named_path( [:tag_config, '*'],     provider_dir ))
-        @common   = load_all_json(Path.named_path( :common_config,         provider_dir ))['common']
-        @provider = load_all_json(Path.named_path( :provider_config,       provider_dir ))['provider']
-        @nodes    = load_all_json(Path.named_path( [:node_config, '*'],    provider_dir ))
+        @services = load_all_json(Path.named_path([:service_config, '*'], provider_dir))
+        @tags     = load_all_json(Path.named_path([:tag_config, '*'],     provider_dir))
+        @nodes    = load_all_json(Path.named_path([:node_config, '*'],    provider_dir))
+        @common   = load_json(Path.named_path(:common_config,   provider_dir))
+        @provider = load_json(Path.named_path(:provider_config, provider_dir))
 
         Util::assert!(@provider, "Failed to load provider.json")
         Util::assert!(@common, "Failed to load common.json")
@@ -105,10 +105,10 @@ module LeapCli
 
       private
 
-      def load_all_json(pattern, config_type = :class)
+      def load_all_json(pattern)
         results = Config::ObjectList.new
         Dir.glob(pattern).each do |filename|
-          obj = load_json(filename, config_type)
+          obj = load_json(filename)
           if obj
             name = File.basename(filename).sub(/\.json$/,'')
             obj['name'] ||= name
@@ -118,9 +118,7 @@ module LeapCli
         results
       end
 
-      def load_json(filename, config_type)
-        #log2 { filename.sub(/^#{Regexp.escape(Path.root)}/,'') }
-
+      def load_json(filename)
         #
         # read file, strip out comments
         # (File.read(filename) would be faster, but we like ability to have comments)
@@ -133,9 +131,8 @@ module LeapCli
           end
         end
 
-        # parse json, and flatten hash
+        # parse json
         begin
-          #hash = Oj.load(buffer.string) || {}
           hash = JSON.parse(buffer.string, :object_class => Hash, :array_class => Array) || {}
         rescue SyntaxError => exc
           log0 'Error in file "%s":' % filename
index e04435361cc0311e437a31dabc8a2140f54fcf67..06a4fef86ed14fee9e83ab825c9e8b0cd47e8f3b 100644 (file)
@@ -79,8 +79,6 @@ module LeapCli
           end
         elsif self.has_key?(key)
           evaluate_value(key)
-        elsif @node != self
-          @node.get!(key)
         else
           raise NoMethodError.new(key, "No method '#{key}' for #{self.class}")
         end
@@ -110,7 +108,7 @@ module LeapCli
       #
       def deep_merge!(object)
         object.each do |key,new_value|
-          old_value = self[key]
+          old_value = self.fetch key, nil
           if old_value.is_a?(Hash) || new_value.is_a?(Hash)
             # merge hashes
             value = Config::Object.new(@manager, @node)
@@ -152,7 +150,7 @@ module LeapCli
         else
           if value =~ /^= (.*)$/
             begin
-              value = eval($1, self.send(:binding))
+              value = eval($1, @node.send(:binding))
               self[key] = value
             rescue SystemStackError => exc
               puts "STACK OVERFLOW, BAILING OUT"
index 9b4e3c9e341f511cfca90025b58b0db0ed1f2d49..aa20e17b4a64bad95bb2c98363945879e7e3f69f 100644 (file)
@@ -23,7 +23,12 @@ module LeapCli; module Path
     :hiera            => 'hiera/#{arg}.yaml',
     :node_ssh_pub_key => 'files/nodes/#{arg}/#{arg}_ssh_key.pub',
     :known_hosts      => 'files/ssh/known_hosts',
-    :authorized_keys  => 'files/ssh/authorized_keys'
+    :authorized_keys  => 'files/ssh/authorized_keys',
+    :ca_key           => 'files/ca/ca.key',
+    :ca_cert          => 'files/ca/ca.crt',
+    :dh_params        => 'files/ca/dh.pem',
+    :node_x509_key    => 'files/nodes/#{arg}/#{arg}.key',
+    :node_x509_cert   => 'files/nodes/#{arg}/#{arg}.crt'
   }
 
   #
@@ -132,7 +137,12 @@ module LeapCli; module Path
   #
   def self.named_path(name, provider_dir=Path.provider)
     if name.is_a? Array
-      name, arg = name
+      if name.length > 2
+        arg = name[1..-1]
+        name = name[0]
+      else
+        name, arg = name
+      end
     else
       arg = nil
     end
index 3b0c3345700c3161ec999486268a84245e0457f6..3bfb66b9f404453e10cd0126f512f48b5851224d 100644 (file)
@@ -67,6 +67,41 @@ module LeapCli
       return output
     end
 
+    def assert_files_missing!(*files)
+      options = files.last.is_a?(Hash) ? files.pop : {}
+      file_list = files.collect { |file_path|
+        file_path = Path.named_path(file_path)
+        File.exists?(file_path) ? relative_path(file_path) : nil
+      }.compact
+      if file_list.length > 1
+        bail! "Sorry, we can't continue because these files already exist: #{file_list.join(', ')}. You are not supposed to remove these files. Do so only with caution."
+      elsif file_list.length == 1
+        bail! "Sorry, we can't continue because this file already exists: #{file_list}. You are not supposed to remove this file. Do so only with caution."
+      end
+    end
+
+    def assert_config!(conf_path)
+      value = nil
+      begin
+        value = eval(conf_path, manager.send(:binding))
+      rescue NoMethodError
+      end
+      assert! value, "* Error: Nothing set for #{conf_path}"
+    end
+
+    def assert_files_exist!(*files)
+      options = files.last.is_a?(Hash) ? files.pop : {}
+      file_list = files.collect { |file_path|
+        file_path = Path.named_path(file_path)
+        !File.exists?(file_path) ? relative_path(file_path) : nil
+      }.compact
+      if file_list.length > 1
+        bail! "Sorry, you are missing these files: #{file_list.join(', ')}. #{options[:msg]}"
+      elsif file_list.length == 1
+        bail! "Sorry, you are missing this file: #{file_list.join(', ')}. #{options[:msg]}"
+      end
+    end
+
     ##
     ## FILES AND DIRECTORIES
     ##
@@ -176,14 +211,9 @@ module LeapCli
       end
     end
 
-    #def rename_file(filepath)
-    #end
-
-    #private
-
-    ##
-    ## PRIVATE HELPER METHODS
-    ##
+    def cmd_exists?(cmd)
+      `which #{cmd}`.strip.chars.any?
+    end
 
     #
     # compares md5 fingerprints to see if the contents of a file match the string we have in memory
@@ -198,6 +228,34 @@ module LeapCli
       end
     end
 
+    ##
+    ## PROCESSES
+    ##
+
+    #
+    # run a long running block of code in a separate process and display marching ants as time goes by.
+    # if the user hits ctrl-c, the program exits.
+    #
+    def long_running(&block)
+      pid = fork
+      if pid == nil
+        yield
+        exit!
+      end
+      Signal.trap("SIGINT") do
+        Process.kill("KILL", pid)
+        Process.wait(pid)
+        bail!
+      end
+      while true
+        sleep 0.2
+        STDOUT.print '.'
+        STDOUT.flush
+        break if Process.wait(pid, Process::WNOHANG)
+      end
+      STDOUT.puts
+    end
+
   end
 end
 
index 366e5a28b2b3afe12c9765d85db49cabe9307888..437d861a3b7222d807c1872c0564eb7cd5177ba1 100644 (file)
@@ -2,6 +2,6 @@ module LeapCli
   unless defined?(LeapCli::VERSION)
     VERSION = '0.1.0'
     SUMMARY = 'Command line interface to the LEAP platform'
-    DESCRIPTION = 'Provides the command "leap", used to manage a bevy of servers running the LEAP platform from the comfort of your own home.'
+    DESCRIPTION = 'The command "leap" can be used to manage a bevy of servers running the LEAP platform from the comfort of your own home.'
   end
 end
index 8f8355871fa0ffcdc032022511879e4c303013a5..9e19836db2c6cae262d82f1576691986ae78b030 100644 (file)
@@ -17,4 +17,9 @@
     "known_hosts": "= file :known_hosts",
     "port": 22
   }
+  #"x509": {
+  #  "use": false,
+  #  "cert": "= x509.use ? file(:node_x509_cert) : nil",
+  #  "key": "= x509.use ? file(:node_x509_key) : nil"
+  #}
 }
index 4e8bb3436f4dec1d75d18fdec1eb062f345a0806..d4153a6c2c781800df0a07e83b54a973f771cb50 100644 (file)
   "enrollment_policy": "open",
   "ca": {
     "name": "Rewire Root CA",
-    "organization": "#{name}",
-    "bit_size": 4096
+    "organization": "= global.provider.name[global.provider.default_language]",
+    "organizational_unit": "= 'https://' + global.common.domain.full_suffix",
+    "bit_size": 4096,
+    "server_certificates": {
+      "bit_size": 3248,
+      "life_span": "1y"
+    }
   }
 }
\ No newline at end of file
index 86d6c14f91b8026853167c9243845520ac0a15cc..629c5b799d42d9ef27fa51ed553ab3768be361d0 100644 (file)
@@ -5,9 +5,12 @@
     "nat": true,
     "ca_crt": "= file 'ca/ca.crt'",
     "ca_key": "= file 'ca/ca.key'",
-    "dh_key": "= file 'ca/dh.key'",
+    "dh": "= file 'ca/dh.pem'",
     "server_crt": "= file domain.name + '.crt'",
     "server_key": "= file domain.name + '.key'"
   },
   "service_type": "user_service"
+  #"x509": {
+  #  "use": true
+  #}
 }