diff --git a/management/buy_certificate.py b/management/buy_certificate.py new file mode 100755 index 00000000..cf895679 --- /dev/null +++ b/management/buy_certificate.py @@ -0,0 +1,155 @@ +#!/usr/bin/python3 + +# Helps you purchase a SSL certificate from Gandi.net using +# their API. +# +# Before you begin: +# 1) Create an account on Gandi.net. +# 2) Pre-pay $16 into your account at https://www.gandi.net/prepaid/operations. Wait until the payment goes through. +# 3) Activate your API key first on the test platform (wait a while, refresh the page) and then activate the production API at https://www.gandi.net/admin/api_key. + +import sys, re, os.path, urllib.request +import xmlrpc.client +import rtyaml + +from utils import load_environment, shell +from web_update import get_web_domains, get_domain_ssl_files, get_web_root +from whats_next import check_certificate + +def buy_ssl_certificate(api_key, domain, command, env): + if domain != env['PUBLIC_HOSTNAME'] \ + and domain not in get_web_domains(env): + raise ValueError("Domain is not %s or a domain we're serving a website for." % env['PUBLIC_HOSTNAME']) + + # Initialize. + + gandi = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/') + + try: + existing_certs = gandi.cert.list(api_key) + except Exception as e: + if "Invalid API key" in str(e): + print("Invalid API key. Check that you copied the API Key correctly from https://www.gandi.net/admin/api_key.") + sys.exit(1) + else: + raise + + # Where is the SSL cert stored? + + ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env) + + # Have we already created a cert for this domain? + + for cert in existing_certs: + if cert['cn'] == domain: + break + else: + # No existing cert found. Purchase one. + if command != 'purchase': + print("No certificate or order found yet. If you haven't yet purchased a certificate, run ths script again with the 'purchase' command. Otherwise wait a moment and try again.") + sys.exit(1) + else: + # Start an order for a single standard SSL certificate. + # Use DNS validation. Web-based validation won't work because they + # require a file on HTTP but not HTTPS w/o redirects and we don't + # serve anything plainly over HTTP. Email might be another way but + # DNS is easier to automate. + op = gandi.cert.create(api_key, { + "csr": open(ssl_csr_path).read(), + "dcv_method": "dns", + "duration": 1, # year? + "package": "cert_std_1_0_0", + }) + print("An SSL certificate has been ordered.") + print() + print(op) + print() + print("In a moment please run this script again with the 'setup' command.") + + if cert['status'] == 'pending': + # Get the information we need to update our DNS with a code so that + # Gandi can verify that we own the domain. + + dcv = gandi.cert.get_dcv_params(api_key, { + "csr": open(ssl_csr_path).read(), + "cert_id": cert['id'], + "dcv_method": "dns", + "duration": 1, # year? + "package": "cert_std_1_0_0", + }) + if dcv["dcv_method"] != "dns": + raise Exception("Certificate ordered with an unknown validation method.") + + # Update our DNS data. + + dns_config = env['STORAGE_ROOT'] + '/dns/custom.yaml' + if os.path.exists(dns_config): + dns_records = rtyaml.load(open(dns_config)) + else: + dns_records = { } + + qname = dcv['md5'] + '.' + domain + value = dcv['sha1'] + '.comodoca.com.' + dns_records[qname] = { "CNAME": value } + + with open(dns_config, 'w') as f: + f.write(rtyaml.dump(dns_records)) + + shell('check_call', ['tools/dns_update']) + + # Okay, done with this step. + + print("DNS has been updated. Gandi will check within 60 minutes.") + print() + print("See https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id']) + + elif cert['status'] == 'valid': + # The certificate is ready. + + # Check before we overwrite something we shouldn't. + if os.path.exists(ssl_certificate): + cert_status = check_certificate(ssl_certificate) + if cert_status != "SELF-SIGNED": + print("Please back up and delete the file %s so I can save your new certificate." % ssl_certificate) + sys.exit(1) + + # Form the certificate. + + # The certificate comes as a long base64-encoded string. Break in + # into lines in the usual way. + pem = "-----BEGIN CERTIFICATE-----\n" + pem += "\n".join(chunk for chunk in re.split(r"(.{64})", cert['cert']) if chunk != "") + pem += "\n-----END CERTIFICATE-----\n\n" + + # Append intermediary certificates. + pem += urllib.request.urlopen("https://www.gandi.net/static/CAs/GandiStandardSSLCA.pem").read().decode("ascii") + + # Write out. + + with open(ssl_certificate, "w") as f: + f.write(pem) + + print("The certificate has been installed in %s. Restarting services..." % ssl_certificate) + + # Restart dovecot and if this is for PUBLIC_HOSTNAME. + + if domain == env['PUBLIC_HOSTNAME']: + shell('check_call', ["/usr/sbin/service", "dovecot", "restart"]) + shell('check_call', ["/usr/sbin/service", "postfix", "restart"]) + + # Restart nginx in all cases. + + shell('check_call', ["/usr/sbin/service", "nginx", "restart"]) + + else: + print("The certificate has an unknown status. Please check https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id']) + +if __name__ == "__main__": + if len(sys.argv) < 4: + print("Usage: python management/buy_certificate.py gandi_api_key domain_name {purchase, setup}") + sys.exit(1) + api_key = sys.argv[1] + domain_name = sys.argv[2] + cmd = sys.argv[3] + buy_ssl_certificate(api_key, domain_name, cmd, load_environment()) + diff --git a/management/web_update.py b/management/web_update.py index c13459c1..6f034db6 100644 --- a/management/web_update.py +++ b/management/web_update.py @@ -43,11 +43,9 @@ def do_web_update(env): def make_domain_config(domain, template, env): # How will we configure this domain. - # Where will its root directory be for static files? Try STORAGE_ROOT/web/domain_name - # if it exists, but fall back to STORAGE_ROOT/web/default. - for test_domain in (domain, 'default'): - root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain)) - if os.path.exists(root): break + # Where will its root directory be for static files? + + root = get_web_root(domain, env) # What private key and SSL certificate will we use for this domain? ssl_key, ssl_certificate, csr_path = get_domain_ssl_files(domain, env) @@ -64,6 +62,13 @@ def make_domain_config(domain, template, env): nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate) return nginx_conf +def get_web_root(domain, env): + # Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default. + for test_domain in (domain, 'default'): + root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain)) + if os.path.exists(root): break + return root + def get_domain_ssl_files(domain, env): # What SSL private key will we use? Allow the user to override this, but # in many cases using the same private key for all domains would be fine. diff --git a/management/whats_next.py b/management/whats_next.py index 206cc870..dca23eac 100755 --- a/management/whats_next.py +++ b/management/whats_next.py @@ -4,6 +4,8 @@ # SSL certificates have been signed, etc., and if not tells the user # what to do next. +__ALL__ = ['check_certificate'] + import os, os.path, re, subprocess import dns.reversename, dns.resolver @@ -175,23 +177,11 @@ def check_ssl_cert(domain, env): print_error("The SSL certificate file for this domain is missing.") return - # Check that the certificate is good. In order to verify with openssl, we need to split out any - # intermediary certificates in the chain (if any) from our certificate (at the top). + # Check that the certificate is good. - cert = open(ssl_certificate).read() - mycert, chaincerts = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S).groups() + cert_status = check_certificate(ssl_certificate) - # This command returns a non-zero exit status in most cases, so trap errors. - retcode, verifyoutput = shell('check_output', [ - "openssl", - "verify", "-verbose", - "-purpose", "sslserver", "-policy_check",] - + ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"]) - + [ssl_certificate], - input=chaincerts.encode('ascii'), - trap=True) - - if "self signed" in verifyoutput: + if cert_status == "SELF-SIGNED": fingerprint = shell('check_output', [ "openssl", "x509", @@ -216,15 +206,48 @@ def check_ssl_cert(domain, env): If you receive intermediate certificates, use a text editor and paste your certificate on top and then the intermediate certificates below it. Save the file and place it onto this machine at %s.""" % ssl_certificate) - - elif retcode == 0: + elif cert_status == "OK": print_ok("SSL certificate is signed.") + else: print_error("The SSL certificate has a problem:") print("") - print(verifyoutput.strip()) + print(cert_status) print("") +def check_certificate(ssl_certificate): + # Use openssl verify to check the status of a certificate. + + # In order to verify with openssl, we need to split out any + # intermediary certificates in the chain (if any) from our + # certificate (at the top). They need to be passed separately. + + cert = open(ssl_certificate).read() + m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S) + if m == None: + return "The certificate file is an invalid PEM certificate." + mycert, chaincerts = m.groups() + + # This command returns a non-zero exit status in most cases, so trap errors. + + retcode, verifyoutput = shell('check_output', [ + "openssl", + "verify", "-verbose", + "-purpose", "sslserver", "-policy_check",] + + ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"]) + + [ssl_certificate], + input=chaincerts.encode('ascii'), + trap=True) + + if "self signed" in verifyoutput: + # Certificate is self-signed. + return "SELF-SIGNED" + elif retcode == 0: + # Certificate is OK. + return "OK" + else: + return verifyoutput.strip() + def print_ok(message): print_block(message, first_line="✓ ") diff --git a/setup/web.sh b/setup/web.sh index 1fda1331..8d015b1f 100755 --- a/setup/web.sh +++ b/setup/web.sh @@ -22,8 +22,8 @@ if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_RO mkdir -p $STORAGE_ROOT/www/default if [ ! -f STORAGE_ROOT/www/default/index.html ]; then cp conf/www_default.html $STORAGE_ROOT/www/default/index.html - chown -R $STORAGE_USER $STORAGE_ROOT/www/default/index.html fi +chown -R $STORAGE_USER $STORAGE_ROOT/www # Create an init script to start the PHP FastCGI daemon and keep it # running after a reboot. Allows us to serve Roundcube for webmail. diff --git a/tools/dns_update b/tools/dns_update new file mode 100755 index 00000000..f8ee4b2d --- /dev/null +++ b/tools/dns_update @@ -0,0 +1,2 @@ +#!/bin/bash +curl -s -d POSTDATA --user $(