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.
- 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"
|