letsencrypt_webfaction 2.2.3 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,55 @@
1
+ require 'letsencrypt_webfaction/options'
2
+
3
+ require 'pathname'
4
+ require 'fileutils'
5
+ require 'openssl'
6
+
7
+ module LetsencryptWebfaction
8
+ module Application
9
+ class Init
10
+ def initialize(_); end # rubocop:disable Naming/UncommunicativeMethodParamName
11
+
12
+ def run!
13
+ copy_config_file
14
+ create_private_key
15
+ output_next_steps
16
+ # TODO: Create crontab entry
17
+ # TODO: Make sure that configuration file has a "this has been configured" flag
18
+ # TODO: Add a bash binary type thingy
19
+ # TODO: Add an installer command?
20
+ end
21
+
22
+ private
23
+
24
+ def copy_config_file
25
+ source = File.expand_path(File.join(__dir__, '../../../templates/letsencrypt_webfaction.toml'))
26
+ if Options.default_options_path.exist?
27
+ puts 'Config file already exists. Skipping copy...'
28
+ else
29
+ FileUtils.cp(source, Dir.home)
30
+ puts 'Copied configuration file'
31
+ end
32
+ end
33
+
34
+ def create_private_key
35
+ # Create config dir.
36
+ FileUtils.mkdir_p(Options.default_config_path)
37
+
38
+ key_path = Options.default_config_path.join('account_key.pem')
39
+ if key_path.exist?
40
+ puts 'Account private key already exists. Skipping generation...'
41
+ else
42
+ # Create private key
43
+ # TODO: Make key size configurable.
44
+ private_key = OpenSSL::PKey::RSA.new(4096)
45
+ Options.default_config_path.join('account_key.pem').write(private_key.to_pem)
46
+ puts 'Generated and stored account private key'
47
+ end
48
+ end
49
+
50
+ def output_next_steps
51
+ puts 'Your system is set up. Next, edit the config file: run `nano ~/letsencrypt_webfaction.yml`.'
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,133 @@
1
+ require 'letsencrypt_webfaction/options'
2
+ require 'letsencrypt_webfaction/errors'
3
+ require 'letsencrypt_webfaction/webfaction_api_credentials'
4
+ require 'letsencrypt_webfaction/certificate_issuer'
5
+ require 'letsencrypt_webfaction/logger_output'
6
+
7
+ require 'acme-client'
8
+ require 'optparse'
9
+
10
+ module LetsencryptWebfaction
11
+ module Application
12
+ class Run
13
+ RENEWAL_DELTA = 14 # days
14
+
15
+ def initialize(args)
16
+ parse_quiet(args)
17
+
18
+ # TODO: args should be supported: --config
19
+ unless Options.default_options_path.exist?
20
+ $stderr.puts 'The configuration file is missing.'
21
+ $stderr.puts 'You may need to run `letsencrypt_webfaction init`'
22
+ raise AppExitError, 'config missing'
23
+ end
24
+ @options = LetsencryptWebfaction::Options.from_toml(Options.default_options_path)
25
+ end
26
+
27
+ def run!
28
+ validate_options
29
+
30
+ # Check credentials
31
+ unless api_credentials.valid?
32
+ $stderr.puts 'WebFaction API username, password, and/or servername are incorrect. Login failed.'
33
+ raise AppExitError, 'WebFaction credentials failed'
34
+ end
35
+
36
+ register_key
37
+
38
+ process_certs
39
+ end
40
+
41
+ private
42
+
43
+ def parse_quiet(args)
44
+ OptionParser.new do |opts|
45
+ opts.banner = 'Usage: letsencrypt_webfaction run [options]'
46
+
47
+ opts.on('--quiet', 'Run with minimal output (useful for cron)') do |q|
48
+ Out.quiet = q
49
+ end
50
+ end.parse!(args)
51
+ end
52
+
53
+ def process_certs # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
54
+ wf_cert_list = api_credentials.call('list_certificates')
55
+ @options.certificates.each do |cert|
56
+ wf_cert = wf_cert_list.find { |c| c['name'] == cert.cert_name }
57
+ if wf_cert.nil?
58
+ # Issue because nonexistent
59
+ Out.puts "Issuing #{cert.cert_name} for the first time."
60
+ elsif wf_cert['domains'].split(',').map(&:strip).sort == cert.domains.sort
61
+ days_remaining = (Date.parse(wf_cert['expiry_date']) - Date.today).to_i
62
+ if days_remaining < RENEWAL_DELTA
63
+ # Renew because nearing expiration
64
+ Out.puts "#{days_remaining} days until expiration of #{cert.cert_name}. Renewing..."
65
+ else
66
+ # Ignore because active
67
+ Out.puts "#{days_remaining} days until expiration of #{cert.cert_name}. Skipping..."
68
+ next
69
+ end
70
+ else
71
+ # Reissue because different
72
+ Out.puts "Reissuing #{cert.cert_name} due to a change in the domain list."
73
+ end
74
+
75
+ CertificateIssuer.new(certificate: cert, api_credentials: api_credentials, client: client).call
76
+ end
77
+ end
78
+
79
+ def api_credentials
80
+ @_api_credentials ||= LetsencryptWebfaction::WebfactionApiCredentials.new username: @options.username, password: @options.password, servername: @options.servername, api_server: @options.api_url
81
+ end
82
+
83
+ def validate_options # rubocop:disable Metrics/MethodLength
84
+ return if @options.valid?
85
+ $stderr.puts 'The configuration file has an error:'
86
+ @options.errors.each do |field, error|
87
+ case error
88
+ when String
89
+ print_error(field, error)
90
+ when Array
91
+ error.each { |inner_field, inner_err| print_error("#{field} #{inner_field}", inner_err) }
92
+ else
93
+ # :nocov:
94
+ raise 'Unexpected internal error type'
95
+ # :nocov:
96
+ end
97
+ end
98
+ raise AppExitError, 'config invalid'
99
+ end
100
+
101
+ def print_error(field, error)
102
+ $stderr.puts "#{field} #{error}"
103
+ end
104
+
105
+ def private_key
106
+ @_private_key ||= begin
107
+ key_path = Options.default_config_path.join('account_key.pem')
108
+ unless key_path.exist?
109
+ $stderr.puts 'Account key missing'
110
+ raise AppExitError, 'Account key missing'
111
+ end
112
+ OpenSSL::PKey::RSA.new(Options.default_config_path.join('account_key.pem').read)
113
+ end
114
+ end
115
+
116
+ def client
117
+ @_client ||= Acme::Client.new(private_key: private_key, endpoint: @options.endpoint)
118
+ end
119
+
120
+ def register_key
121
+ # If the private key is not known to the server, we need to register it for the first time.
122
+ registration = client.register(contact: "mailto:#{@options.letsencrypt_account_email}")
123
+
124
+ # You'll may need to agree to the term (that's up the to the server to require it or not but boulder does by default)
125
+ registration.agree_terms
126
+ rescue Acme::Client::Error::Malformed => e
127
+ # Stupid hack if the registration already exists.
128
+ return if e.message == 'Registration key is already in use'
129
+ raise
130
+ end
131
+ end
132
+ end
133
+ end
@@ -2,8 +2,6 @@ require 'xmlrpc/client'
2
2
 
3
3
  module LetsencryptWebfaction
4
4
  class CertificateInstaller
5
- WEBFACTION_API_VERSION = 2
6
-
7
5
  def initialize(cert_name, certificate, credentials)
8
6
  @cert_name = cert_name
9
7
  @certificate = certificate
@@ -0,0 +1,52 @@
1
+ require 'acme-client'
2
+ require 'letsencrypt_webfaction/domain_validator'
3
+ require 'letsencrypt_webfaction/certificate_installer'
4
+
5
+ module LetsencryptWebfaction
6
+ class CertificateIssuer
7
+ def initialize(certificate:, api_credentials:, client:)
8
+ @cert_config = certificate
9
+ @api_credentials = api_credentials
10
+ @client = client
11
+ end
12
+
13
+ def call
14
+ # Validate the domains.
15
+ return unless validator.validate!
16
+
17
+ # Write the obtained certificates.
18
+ certificate_installer.install!
19
+
20
+ output_success_help
21
+ end
22
+
23
+ private
24
+
25
+ def validator
26
+ @_validator ||= LetsencryptWebfaction::DomainValidator.new @cert_config.domains, @client, @cert_config.public_dirs
27
+ end
28
+
29
+ def certificate_installer
30
+ @_certificate_installer ||= LetsencryptWebfaction::CertificateInstaller.new(@cert_config.cert_name, certificate, @api_credentials)
31
+ end
32
+
33
+ def certificate
34
+ # We can now request a certificate, you can pass anything that returns
35
+ # a valid DER encoded CSR when calling to_der on it, for example a
36
+ # OpenSSL::X509::Request too.
37
+ @_certificate ||= @client.new_certificate(csr)
38
+ end
39
+
40
+ def csr
41
+ # We're going to need a certificate signing request. If not explicitly
42
+ # specified, the first name listed becomes the common name.
43
+ @_csr ||= Acme::Client::CertificateRequest.new(names: @cert_config.domains)
44
+ end
45
+
46
+ def output_success_help
47
+ Out.puts 'Your new certificate is now created and installed.'
48
+ Out.puts "You will need to change your application to use the #{@cert_config.cert_name} certificate."
49
+ Out.puts 'Add the `--quiet` parameter in your cron task to remove this message.'
50
+ end
51
+ end
52
+ end
@@ -66,7 +66,7 @@ module LetsencryptWebfaction
66
66
  @challenge = challenge
67
67
  end
68
68
 
69
- def print_error
69
+ def print_error # rubocop:disable Metrics/MethodLength
70
70
  case @challenge.authorization.verify_status
71
71
  when 'valid'
72
72
  $stderr.puts "#{@domain}: Success"
@@ -0,0 +1,5 @@
1
+ module LetsencryptWebfaction
2
+ class Error < StandardError; end
3
+ class InvalidConfigValueError < Error; end
4
+ class AppExitError < Error; end
5
+ end
@@ -0,0 +1,14 @@
1
+ module LetsencryptWebfaction
2
+ class LoggerOutput
3
+ attr_accessor :quiet
4
+ def initialize(quiet: false)
5
+ @quiet = quiet
6
+ end
7
+
8
+ def puts(msg)
9
+ Kernel.puts msg unless @quiet
10
+ end
11
+ end
12
+
13
+ Out = LoggerOutput.new
14
+ end
@@ -0,0 +1,72 @@
1
+ require 'toml-rb'
2
+ require 'socket'
3
+
4
+ require 'letsencrypt_webfaction/options/certificate'
5
+
6
+ module LetsencryptWebfaction
7
+ class Options
8
+ NON_BLANK_FIELDS = %i[username password letsencrypt_account_email endpoint api_url servername].freeze
9
+
10
+ WEBFACTION_API_URL = 'https://api.webfaction.com/'.freeze
11
+
12
+ def initialize(args)
13
+ @config = args
14
+ # Fetch options
15
+ # Validate options
16
+ end
17
+
18
+ def self.from_toml(path)
19
+ new TomlRB.parse(path.read)
20
+ end
21
+
22
+ def self.default_options_path
23
+ Pathname.new(Dir.home).join('letsencrypt_webfaction.toml')
24
+ end
25
+
26
+ def self.default_config_path
27
+ Pathname.new(Dir.home).join('.config', 'letsencrypt_webfaction')
28
+ end
29
+
30
+ def username
31
+ @config['username']
32
+ end
33
+
34
+ def password
35
+ @config['password']
36
+ end
37
+
38
+ def letsencrypt_account_email
39
+ @config['letsencrypt_account_email']
40
+ end
41
+
42
+ def endpoint
43
+ @config['endpoint']
44
+ end
45
+
46
+ def api_url
47
+ @config['api_url'] || WEBFACTION_API_URL
48
+ end
49
+
50
+ def servername
51
+ @config['servername'] || Socket.gethostname.split('.')[0].sub(/^./, &:upcase)
52
+ end
53
+
54
+ def certificates
55
+ @_certs ||= @config['certificate'].map { |cert| Certificate.new(cert) }
56
+ end
57
+
58
+ def errors
59
+ {}.tap do |e|
60
+ NON_BLANK_FIELDS.each do |field|
61
+ e[field] = "can't be blank" if public_send(field).nil? || public_send(field) == ''
62
+ end
63
+ cert_errors = certificates.map(&:errors).reject(&:empty?)
64
+ e[:certificate] = cert_errors if cert_errors.any?
65
+ end
66
+ end
67
+
68
+ def valid?
69
+ errors.none?
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,50 @@
1
+ module LetsencryptWebfaction
2
+ class Options
3
+ class Certificate
4
+ SUPPORTED_VALIDATION_METHODS = ['http01'].freeze
5
+ VALID_CERT_NAME = /[^a-zA-Z\d_]/
6
+ VALID_KEY_SIZES = [2048, 4096].freeze
7
+
8
+ def initialize(args)
9
+ @args = args
10
+ end
11
+
12
+ def domains
13
+ return [] if @args['domains'].nil? || @args['domains'] == ''
14
+ Array(@args['domains'])
15
+ end
16
+
17
+ def validation_method
18
+ @args['method'] || 'http01'
19
+ end
20
+
21
+ def public_dirs
22
+ return [] if @args['public'].nil? || @args['public'] == ''
23
+ Array(@args['public'])
24
+ end
25
+
26
+ def cert_name
27
+ if @args['name'].nil? && domains.any?
28
+ domains[0].gsub(VALID_CERT_NAME, '_')
29
+ else
30
+ @args['name']
31
+ end
32
+ end
33
+
34
+ def key_size
35
+ @args['key_size'] || 4096
36
+ end
37
+
38
+ def errors # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
39
+ {}.tap do |e|
40
+ e[:domains] = "can't be empty" if domains.none?
41
+ e[:method] = 'must be "http01"' unless SUPPORTED_VALIDATION_METHODS.include?(validation_method)
42
+ e[:public] = "can't be empty" if public_dirs.none?
43
+ e[:name] = "can't be blank" if cert_name.nil? || cert_name == ''
44
+ e[:name] = 'can only include letters, numbers, and underscores' if cert_name =~ VALID_CERT_NAME
45
+ e[:key_size] = "must be one of #{VALID_KEY_SIZES.join(', ')}" unless VALID_KEY_SIZES.include?(key_size)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,58 @@
1
+ # Your Webfaction username and password
2
+ username = "myusername"
3
+ password = "mypassword"
4
+
5
+ # The email address which will be used to register with Let's Encrypt.
6
+ letsencrypt_account_email = "me@example.com"
7
+
8
+ # The ACME endpoint. Use the staging server until you get everything working.
9
+ # Then switch to the production endpoint.
10
+ endpoint = "https://acme-staging.api.letsencrypt.org/" # Staging
11
+ #endpoint = "https://acme-v01.api.letsencrypt.org/" # Production
12
+
13
+ # The URL to the WebFaction API. You should not change this under normal
14
+ # circumstances.
15
+ #api_url = "https://api.webfaction.com/"
16
+
17
+ # The hostname of the server you are on. Should be autodetected and not need to
18
+ # be changed.
19
+ #servername = "web123"
20
+
21
+ [[certificate]]
22
+ # The list of domains for which the cert should be issued. The first will be
23
+ # the common name.
24
+ domains = [
25
+ "test.example.com",
26
+ "test1.example.com",
27
+ ]
28
+
29
+ # Right now, only http01 is available. This is the default.
30
+ #method = "http01"
31
+
32
+ # The path to the root of your website. Can be an array as in the second example.
33
+ public = "~/webapps/myapp/public_html"
34
+ # public = [
35
+ # "~/webapps/myapp/public_html",
36
+ # "~/webapps/myapp/public_html1",
37
+ # ]
38
+
39
+ # The name of your cert in the WebFaction admin interface. Will default to
40
+ # the cert common name with the dots replaced by underscores. (Optional)
41
+ # NOTE: If you change this and do not also rename it in the webfaction admin,
42
+ # a new certificate will be issued.
43
+ name = "mycertname1"
44
+
45
+ # The size of the private key. 4096 is the default. You can use 2048.
46
+ #key_size = 4096
47
+
48
+
49
+ # A second certificate. All the same keys as above. You should create a
50
+ # new [[certificate]] entry for every certificate you want issued. This is a
51
+ # simplistic example.
52
+ #[[certificate]]
53
+ #domains = [
54
+ # "test2.example.com",
55
+ # "test3.example.com",
56
+ #]
57
+ #public = "~/webapps/myapp/public_html"
58
+ #name = "mycertname1"