Skip to content

Instantly share code, notes, and snippets.

@cataphract
Created July 22, 2016 10:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cataphract/c06177d5409b9c77b9bd42b36c641ecf to your computer and use it in GitHub Desktop.
Save cataphract/c06177d5409b9c77b9bd42b36c641ecf to your computer and use it in GitHub Desktop.
# 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