letsencrypt_webfaction 2.2.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"