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.
- checksums.yaml +5 -5
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/.rubocop.yml +9 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +1 -0
- data/README.md +18 -107
- data/certs/will_in_wi.pem +8 -8
- data/docs/rbenv.md +31 -0
- data/docs/upgrading.md +49 -0
- data/exe/letsencrypt_webfaction +7 -2
- data/letsencrypt_webfaction.gemspec +1 -0
- data/lib/letsencrypt_webfaction.rb +1 -1
- data/lib/letsencrypt_webfaction/application.rb +32 -84
- data/lib/letsencrypt_webfaction/application/init.rb +55 -0
- data/lib/letsencrypt_webfaction/application/run.rb +133 -0
- data/lib/letsencrypt_webfaction/certificate_installer.rb +0 -2
- data/lib/letsencrypt_webfaction/certificate_issuer.rb +52 -0
- data/lib/letsencrypt_webfaction/domain_validator.rb +1 -1
- data/lib/letsencrypt_webfaction/errors.rb +5 -0
- data/lib/letsencrypt_webfaction/logger_output.rb +14 -0
- data/lib/letsencrypt_webfaction/options.rb +72 -0
- data/lib/letsencrypt_webfaction/options/certificate.rb +50 -0
- data/templates/letsencrypt_webfaction.toml +58 -0
- metadata +35 -18
- metadata.gz.sig +0 -0
- data/config.defaults.yml +0 -12
- data/config.example.yml +0 -8
- data/lib/letsencrypt_webfaction/args_parser.rb +0 -143
- data/lib/letsencrypt_webfaction/args_parser/array_validator.rb +0 -9
- data/lib/letsencrypt_webfaction/args_parser/defined_values_validator.rb +0 -13
- data/lib/letsencrypt_webfaction/args_parser/field.rb +0 -48
- data/lib/letsencrypt_webfaction/args_parser/string_validator.rb +0 -9
@@ -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
|
@@ -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
|
@@ -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"
|