summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorelijah <elijah@riseup.net>2016-08-31 14:54:46 -0700
committerelijah <elijah@riseup.net>2016-09-01 10:49:22 -0700
commit8116e007cfd4dbee8282247348cf45473dcde45e (patch)
treeecf8cfbc790ef57c3519c947a1fa76d0c1a4e5a2 /lib
parentd679399af0898b959b8b84a8e8d1e2e03c4e21b5 (diff)
added support for Let's Encrypt
Diffstat (limited to 'lib')
-rw-r--r--lib/leap_cli/acme.rb101
-rw-r--r--lib/leap_cli/commands/ca.rb178
-rw-r--r--lib/leap_cli/config/manager.rb18
-rw-r--r--lib/leap_cli/config/node_cert.rb4
-rw-r--r--lib/leap_cli/config/object.rb26
-rw-r--r--lib/leap_cli/config/object_list.rb6
-rw-r--r--lib/leap_cli/log_filter.rb4
-rw-r--r--lib/leap_cli/ssh/backend.rb7
-rw-r--r--lib/leap_cli/ssh/formatter.rb2
-rw-r--r--lib/leap_cli/ssh/scripts.rb10
10 files changed, 344 insertions, 12 deletions
diff --git a/lib/leap_cli/acme.rb b/lib/leap_cli/acme.rb
new file mode 100644
index 00000000..6c7dbe98
--- /dev/null
+++ b/lib/leap_cli/acme.rb
@@ -0,0 +1,101 @@
+require 'openssl'
+require 'acme-client'
+
+#
+# A little bit of sugar around gem acme-client
+#
+
+module LeapCli
+ class Acme
+
+ if ENV['ACME_STAGING']
+ ENDPOINT = 'https://acme-staging.api.letsencrypt.org/'
+ puts "using endpoint " + ENDPOINT
+ else
+ ENDPOINT = 'https://acme-v01.api.letsencrypt.org/'
+ end
+
+ def initialize(domain: nil, key:)
+ @client = ::Acme::Client.new(
+ private_key: key,
+ endpoint: ENDPOINT,
+ connection_options: {request: {open_timeout: 5, timeout: 5}}
+ )
+ @domain = domain
+ end
+
+ #
+ # static methods
+ #
+
+ def self.new_private_key
+ return OpenSSL::PKey::RSA.new(4096)
+ end
+
+ def self.load_private_key(pem_encoded_key)
+ return OpenSSL::PKey::RSA.new(pem_encoded_key)
+ end
+
+ def self.load_csr(pem_encoded_csr)
+ return OpenSSL::X509::Request.new(pem_encoded_csr)
+ end
+
+ #
+ # instance methods
+ #
+
+ #
+ # register a new account key with CA
+ #
+ def register(contact)
+ registration = @client.register(contact: 'mailto:' + contact)
+ if registration && registration.agree_terms
+ return registration
+ else
+ return false
+ end
+ end
+
+ #
+ # authorize account key for domain
+ #
+ def authorize
+ authorization = @client.authorize(domain: @domain)
+ challenge = nil
+ begin
+ while true
+ if authorization.status == 'pending'
+ challenge = authorization.http01
+ yield challenge
+ challenge.request_verification
+ sleep 1
+ authorization.verify_status
+ if challenge.error
+ return 'error', challenge.error
+ end
+ elsif authorization.status == 'invalid'
+ challenge_msg = (challenge.nil? ? '' : challenge.error)
+ return 'error', 'Something bad happened. %s' % challenge_msg
+ elsif authorization.status == 'valid'
+ return 'valid', nil
+ else
+ challenge_msg = (challenge.nil? ? '' : challenge.error)
+ return 'error', 'status: %s, response message: %s' % [authorization.status, challenge_msg]
+ end
+ end
+ rescue Interrupt
+ return 'error', 'interrupted'
+ end
+ rescue ::Acme::Client::Error::Unauthorized => exc
+ return 'unauthorized', exc.to_s
+ end
+
+ #
+ # get new certificate
+ #
+ def get_certificate(csr)
+ return @client.new_certificate(csr)
+ end
+
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/commands/ca.rb b/lib/leap_cli/commands/ca.rb
index f998d0fe..d9ffa6a4 100644
--- a/lib/leap_cli/commands/ca.rb
+++ b/lib/leap_cli/commands/ca.rb
@@ -38,6 +38,7 @@ module LeapCli; module Commands
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."
@@ -52,6 +53,23 @@ module LeapCli; module Commands
generate_csr(global_options, options, args)
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
+
+ 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
@@ -150,7 +168,7 @@ module LeapCli; module Commands
assert_config! 'provider.ca.server_certificates.digest'
server_certificates = provider.ca.server_certificates
- options[:domain] ||= provider.domain
+ options[:domain] ||= args.first || provider.domain
options[:organization] ||= provider.name[provider.default_language]
options[:country] ||= server_certificates['country']
options[:state] ||= server_certificates['state']
@@ -166,4 +184,162 @@ module LeapCli; module Commands
X509.create_csr_and_cert(options)
end
+ #
+ # letsencrypt.org
+ #
+
+ 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
+ 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
+
+ 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 do_renew_cert(global, options, args)
+ require 'leap_cli/acme'
+ require 'leap_cli/ssh'
+ require 'socket'
+ require 'net/http'
+
+ #
+ # sanity check the domain
+ #
+ domain = args.first
+ nodes = nodes_for_domain(domain)
+ domain_ready_for_acme!(domain)
+
+ #
+ # load key material
+ #
+ assert_files_exist!([:commercial_key, domain], [:commercial_csr, domain],
+ :msg => 'Please create the CSR first with `leap cert csr %s`' % domain)
+ csr = Acme.load_csr(read_file!([:commercial_csr, domain]))
+ assert_files_exist!(:acme_key,
+ :msg => "Please run `leap cert register` first. This only needs to be done once.")
+ account_key = Acme.load_private_key(read_file!(:acme_key))
+
+ #
+ # 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
+
+ log :fetching, "new certificate from letsencrypt.org"
+ cert = acme.get_certificate(csr)
+ write_file!([:commercial_cert, domain], cert.fullchain_to_pem)
+ end
+
+ #
+ # Returns a hash of nodes that match this domain. It also checks:
+ #
+ # * a node configuration has this domain
+ # * the dns for the domain exists
+ #
+ # This method will bail if any checks fail.
+ #
+ 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
+ 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
+
+ #
+ # runs the following checks on the domain:
+ #
+ # * we are able to get /.well-known/acme-challenge/ok
+ #
+ # This method will bail if any checks fail.
+ #
+ 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
+ end
+
end; end
diff --git a/lib/leap_cli/config/manager.rb b/lib/leap_cli/config/manager.rb
index e39334c8..bdd5b255 100644
--- a/lib/leap_cli/config/manager.rb
+++ b/lib/leap_cli/config/manager.rb
@@ -405,15 +405,23 @@ module LeapCli
end
#
- # returns a list of 'control' files for this node.
- # 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.
+ # Returns a list of 'control' files for this node. 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 control_files(node)
files = []
[Path.provider_base, @provider_dir].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$/,'')
diff --git a/lib/leap_cli/config/node_cert.rb b/lib/leap_cli/config/node_cert.rb
index 64842ffa..da63d621 100644
--- a/lib/leap_cli/config/node_cert.rb
+++ b/lib/leap_cli/config/node_cert.rb
@@ -109,10 +109,10 @@ module LeapCli; module Config
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 csr --domain #{domain}`."
+ "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 csr --domain #{domain}`."
+ "You should renew it with `leap cert renew #{domain}`."
end
end
end
diff --git a/lib/leap_cli/config/object.rb b/lib/leap_cli/config/object.rb
index b117c2f0..16c41999 100644
--- a/lib/leap_cli/config/object.rb
+++ b/lib/leap_cli/config/object.rb
@@ -153,6 +153,28 @@ module LeapCli
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
##
@@ -376,12 +398,16 @@ module LeapCli
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
diff --git a/lib/leap_cli/config/object_list.rb b/lib/leap_cli/config/object_list.rb
index f9299a61..80f89d92 100644
--- a/lib/leap_cli/config/object_list.rb
+++ b/lib/leap_cli/config/object_list.rb
@@ -84,6 +84,12 @@ module LeapCli
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
diff --git a/lib/leap_cli/log_filter.rb b/lib/leap_cli/log_filter.rb
index 28504e1a..c73f3a91 100644
--- a/lib/leap_cli/log_filter.rb
+++ b/lib/leap_cli/log_filter.rb
@@ -119,14 +119,14 @@ module LeapCli
{ :match => /created/, :color => :green, :style => :bold },
{ :match => /completed/, :color => :green, :style => :bold },
{ :match => /ran/, :color => :green, :style => :bold },
- { :match => /registered/, :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 },
+ { :match => /^loading/, :color => :magenta },
]
def self.apply_message_filters(message)
diff --git a/lib/leap_cli/ssh/backend.rb b/lib/leap_cli/ssh/backend.rb
index c1afc993..3894d815 100644
--- a/lib/leap_cli/ssh/backend.rb
+++ b/lib/leap_cli/ssh/backend.rb
@@ -120,7 +120,12 @@ module LeapCli
#
def upload!(src, dest, options={})
if options[:mode]
- super(StringIO.new(File.read(src)), dest, options)
+ 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
diff --git a/lib/leap_cli/ssh/formatter.rb b/lib/leap_cli/ssh/formatter.rb
index bab43011..c2e386dc 100644
--- a/lib/leap_cli/ssh/formatter.rb
+++ b/lib/leap_cli/ssh/formatter.rb
@@ -16,7 +16,7 @@ module LeapCli
}
def initialize(logger, host, options={})
- @logger = logger
+ @logger = logger || LeapCli.new_logger
@host = host
@options = DEFAULT_OPTIONS.merge(options)
end
diff --git a/lib/leap_cli/ssh/scripts.rb b/lib/leap_cli/ssh/scripts.rb
index 9fef6240..3dd6b604 100644
--- a/lib/leap_cli/ssh/scripts.rb
+++ b/lib/leap_cli/ssh/scripts.rb
@@ -134,6 +134,16 @@ module LeapCli
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)