Created
July 22, 2016 10:07
-
-
Save cataphract/c06177d5409b9c77b9bd42b36c641ecf to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# vim: si ts=2 sts=2 et sw=2 ft=ruby | |
require 'logger' | |
require 'openssl' | |
require 'digest' | |
require 'fileutils' | |
require 'pathname' | |
require 'acme-client' | |
require 'tempfile' | |
KEY_SIZE = 4096 | |
RENEW_IF_EXPIRY_IN_N_DAYS = 7 | |
ACME_ENDPOINT = 'https://acme-staging.api.letsencrypt.org/' | |
settings = { | |
user: 'root', | |
group: 'ssl-cert', | |
contact: 'mail@geleia.net', | |
domain: 'gitlab.geleia.net', | |
ssl_certificate: '/tmp/cert.pem', # actually full chain (without anchor) | |
ssl_key: '/tmp/key.pem', | |
client_key: '/tmp/client.pem', | |
challenge_dir: '/var/www/html/.well-known/acme-challenge/', | |
} | |
LOGGER = Logger.new(STDOUT) | |
LOGGER.level = Logger::INFO | |
class HttpdService | |
class << self | |
def running? | |
%x{/usr/sbin/service apache2 status} | |
$?.exitstatus == 0 | |
end | |
def reload | |
LOGGER.info "Reloading httpd" | |
%x{/usr/sbin/service apache2 reload} | |
$?.exitstatus == 0 | |
end | |
end | |
end | |
def with_retry(retry_count: 5, wait: 5) | |
count = 0 | |
until yield | |
raise 'timed out' if count > retry_count | |
count = count + 1 | |
LOGGER.info "Will retry, failed attempts: #{count}" | |
sleep wait | |
end | |
end | |
def write_prv_key(path:, owner:, group:, pem_material:) | |
key_temp = Tempfile.new(File.basename(path), File.dirname(path)) | |
key_temp.write pem_material | |
key_temp.close | |
FileUtils.chown owner, group, key_temp.path | |
File.chmod 0640, key_temp.path | |
File.rename key_temp.path, path | |
end | |
class Certificate | |
class << self | |
def load(cert_path:, prv_key_path:) | |
new cert: OpenSSL::X509::Certificate.new(File.read(cert_path)), | |
key: OpenSSL::PKey::RSA.new(File.read(prv_key_path)) | |
end | |
def generate(domain:) | |
prv_key = OpenSSL::PKey::RSA.new(KEY_SIZE) | |
cert = OpenSSL::X509::Certificate.new | |
cert.version = 0x2 | |
cert.serial = rand(2**(16*8)) | |
name = OpenSSL::X509::Name.new | |
name.add_entry 'CN', domain | |
cert.subject = cert.issuer = name | |
cert.not_before = Time.new | |
cert.not_after = cert.not_before + (3600 * 24 * 365 * 10) # 10 years | |
cert.public_key = prv_key.public_key | |
ef = OpenSSL::X509::ExtensionFactory.new | |
ef.subject_certificate = cert | |
ef.issuer_certificate = cert | |
cert.extensions = [ | |
ef.create_extension('basicConstraints','CA:FALSE', true), | |
ef.create_extension('subjectKeyIdentifier', 'hash'), | |
ef.create_extension('keyUsage', 'nonRepudiation,digitalSignature,keyEncipherment', true), | |
ef.create_extension('subjectAltName', "DNS:#{domain}"), | |
ef.create_extension('extendedKeyUsage','serverAuth'), | |
] | |
cert.sign prv_key, OpenSSL::Digest::SHA256.new | |
new cert: cert, key: prv_key | |
end | |
end | |
def initialize(cert:, key:, chain: []) | |
@cert = cert | |
@key = key | |
@chain = chain | |
end | |
def save!(cert_path:, prv_key_path:, owner:, group:) | |
LOGGER.info "Writing certificate to #{cert_path}" | |
IO.write cert_path, ([@cert] + @chain).map(&:to_pem).join | |
FileUtils.chown owner, group, cert_path | |
File.chmod 0644, cert_path | |
LOGGER.info "Writing key of cert to #{prv_key_path}" | |
write_prv_key path: prv_key_path, | |
owner: owner, | |
group: group, | |
pem_material: @key.to_pem | |
end | |
def self_signed? | |
@cert.subject == @cert.issuer | |
end | |
def expire_in?(days:) | |
@cert.not_after - Time.now < days * 24 * 3600 | |
end | |
end | |
module Acme | |
class Client | |
def self.load_or_generate(client_key_path:, contact_email:) | |
if File.file? client_key_path | |
key = OpenSSL::PKey::RSA.new File.read client_key_path | |
need_register = false | |
else | |
LOGGER.info "Generating new key for ACME client" | |
key = OpenSSL::PKey::RSA.new KEY_SIZE | |
need_register = true | |
end | |
client = self.new private_key: key, | |
endpoint: ACME_ENDPOINT, | |
connection_options: { request: { open_timeout: 5, timeout: 5 } } | |
if need_register | |
registration = client.register contact: "mailto:#{contact_email}" | |
raise 'Could not agree to terms' unless registration.agree_terms | |
write_prv_key path: client_key_path, | |
owner: 'root', | |
group: 'root', | |
pem_material: key.to_pem | |
end | |
client | |
end | |
def do_challenge(domain:, challenge_dir:) | |
LOGGER.info "Generating challenge for #{domain}" | |
chal = authorize(domain: domain).http01 | |
raise "Unexpected filename: #{chal.filename}" unless | |
chal.filename =~ /\A\.well-known\/acme-challenge\/[^\/]+\z/ | |
dir = Pathname.new challenge_dir | |
raise "#{dir} is not a directory" unless dir.directory? | |
file = dir + File.basename(chal.filename) | |
File.umask ~ 0644 | |
IO.write file.to_s, chal.file_content | |
LOGGER.info 'Requesting verification' | |
chal.request_verification | |
with_retry do | |
status = chal.verify_status | |
if status == 'pending' | |
LOGGER.info 'Verification is pending' | |
false | |
elsif status == 'valid' | |
LOGGER.info 'Verification returned valid' | |
true | |
else | |
raise "Unexpected status: #{status}" | |
end | |
end | |
end | |
def req_cert(domain:) | |
key = OpenSSL::PKey::RSA.new KEY_SIZE | |
csr = Acme::Client::CertificateRequest.new(names: [domain], private_key: key) | |
acme_cert = new_certificate csr | |
::Certificate.new cert: acme_cert.x509, key: key, chain: acme_cert.x509_chain | |
end | |
end | |
end | |
reload = false | |
unless File.file?(settings[:ssl_certificate]) | |
unless File.file?(settings[:ssl_key]) | |
LOGGER.info "No exisiting ssl cert/key, assuming httpd is not running " \ | |
"and generating self-signed certificate for #{settings[:domain]}" | |
cert = Certificate.generate domain: settings[:domain] | |
else | |
LOGGER.warn "Certificate is present, but not key. Aborting" | |
exit 2 | |
end | |
else # there's a cert already | |
cur_cert = Certificate.load prv_key_path: settings[:ssl_key], | |
cert_path: settings[:ssl_certificate] | |
if cur_cert.self_signed? | |
LOGGER.info "The certificate is self-signed, generating a new one" | |
elsif cur_cert.expire_in? days: RENEW_IF_EXPIRY_IN_N_DAYS | |
LOGGER.info "The certificate will expire in less than " \ | |
"#{RENEW_IF_EXPIRY_IN_N_DAYS} days or less (or expired already)" | |
else | |
LOGGER.debug 'The current certificate is good, doing nothing' | |
exit 0 | |
end | |
client = Acme::Client::load_or_generate client_key_path: settings[:client_key], | |
contact_email: settings[:contact] | |
client.do_challenge domain: settings[:domain], | |
challenge_dir: settings[:challenge_dir] | |
cert = client.req_cert domain: settings[:domain] | |
reload = true | |
end | |
cert.save! prv_key_path: settings[:ssl_key], | |
cert_path: settings[:ssl_certificate], | |
owner: settings[:owner], | |
group: settings[:group] | |
LOGGER.info "Saved new certificate" | |
if reload | |
if HttpdService.running? | |
res = HttpdService.reload | |
raise 'Failed reloading httpd' unless res | |
else | |
LOGGER.warn "Not reloading httpd, as it's not running" | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment