summaryrefslogtreecommitdiff
path: root/lib/leap_cli/commands/cert.rb
blob: 68fa94448b932d2e5baf27d25456ad2c3b797f74 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
module LeapCli; module Commands

  desc "Manage X.509 certificates"
  command :cert do |cert|

    cert.desc 'Creates two Certificate Authorities (one for validating servers and one for validating clients).'
    cert.long_desc 'See see what values are used in the generation of the certificates (like name and key size), run `leap inspect provider` and look for the "ca" property. To see the details of the created certs, run `leap inspect <file>`.'
    cert.command :ca do |ca|
      ca.action do |global_options,options,args|
        assert_config! 'provider.ca.name'
        generate_new_certificate_authority(:ca_key, :ca_cert, provider.ca.name)
        generate_new_certificate_authority(:client_ca_key, :client_ca_cert, provider.ca.name + ' (client certificates only!)')
      end
    end

    cert.desc 'Creates or renews a X.509 certificate/key pair for a single node or all nodes, but only if needed.'
    cert.long_desc 'This command will a generate new certificate for a node if some value in the node has changed ' +
                   'that is included in the certificate (like hostname or IP address), or if the old certificate will be expiring soon. ' +
                   'Sometimes, you might want to force the generation of a new certificate, ' +
                   'such as in the cases where you have changed a CA parameter for server certificates, like bit size or digest hash. ' +
                   'In this case, use --force. If <node-filter> is empty, this command will apply to all nodes.'
    cert.arg_name 'FILTER'
    cert.command :update do |update|
      update.switch 'force', :desc => 'Always generate new certificates', :negatable => false
      update.action do |global_options,options,args|
        update_certificates(manager.filter!(args), options)
      end
    end

    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|
        generate_dh
      end
    end

    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."
      csr.flag ['unit', 'OU'], :arg_name => 'UNIT', :desc => "Set OU in distinguished name."
      csr.flag 'email', :arg_name => 'EMAIL', :desc => "Set emailAddress in distinguished name."
      csr.flag ['locality', 'L'], :arg_name => 'LOCALITY', :desc => "Set L in distinguished name."
      csr.flag ['state', 'ST'], :arg_name => 'STATE', :desc => "Set ST in distinguished name."
      csr.flag ['country', 'C'], :arg_name => 'COUNTRY', :desc => "Set C in distinguished name."
      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|
        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

  #
  # 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'
    assert_config! 'provider.ca.server_certificates.life_span'
    assert_config! 'common.x509.use'

    nodes.each_node do |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] || 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 = X509.cert_serial_number(provider.domain)
    cert.subject.common_name = [prefix, X509.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 = X509.client_ca_root
    cert.sign! X509.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 = X509.new_ca(provider.ca, common_name)

    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

  #
  # 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'

    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

    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 assert_no_errors!(msg)
    yield
  rescue StandardError => exc
    bail! :error, msg do
      log exc.to_s
    end
  end

  def do_renew_cert(global, options, args)
    require 'leap_cli/acme'
    require 'leap_cli/ssh'
    require 'socket'
    require 'net/http'

    csr = nil
    account_key = nil
    cert = nil
    acme = nil

    #
    # 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)
    assert_no_errors!("Could not load #{path([:commercial_csr, domain])}") do
      csr = Acme.load_csr(read_file!([:commercial_csr, domain]))
    end
    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

    #
    # 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.inspect
    elsif status == 'unauthorized'
      bail!(:unauthorized, message.inspect, color: :yellow, style: :bold) do
        log 'You must first run `leap cert register` to register the account key with letsencrypt.org'
      end
    else
      bail!(:error, "unrecognized status: #{status.inspect}, #{message.inspect}")
    end

    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

  #
  # 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)
    uri = URI("https://#{domain}/.well-known/acme-challenge/ok")
    options = {
      use_ssl: true,
      open_timeout: 5,
      verify_mode: OpenSSL::SSL::VERIFY_NONE
    }
    http_get(uri, options)
  end

  private

  def http_get(uri, options, limit = 10)
    raise ArgumentError, "HTTP redirect too deep (#{uri})" if limit == 0
    Net::HTTP.start(uri.host, uri.port, options) do |http|
      http.request(Net::HTTP::Get.new(uri)) do |response|
        case response
        when Net::HTTPSuccess then
          return response
        when Net::HTTPRedirection then
          return http_get(URI(response['location']), options, limit - 1)
        else
          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