Skip to content

Instantly share code, notes, and snippets.

@jlandure
Forked from brianmhunt/dispatch.yaml
Created March 7, 2016 15:31
Show Gist options
  • Save jlandure/ceca4905919e66df7f55 to your computer and use it in GitHub Desktop.
Save jlandure/ceca4905919e66df7f55 to your computer and use it in GitHub Desktop.
ACME + AppEngine
application: some-dummy-app-name # overridden by -A
dispatch:
# Let's Encrypt ACME challenge service
- url: "*/.well-known/acme-challenge/*"
module: acme
application: some-dummy-app-name # since we use -A this is irrelevant
version: acme-0
runtime: python27
threadsafe: true
api_version: 1
module: acme
default_expiration: 1m
handlers:
- url: /\.well-known/acme-challenge/(.*)
upload: challenges/(.*)
static_files: challenges/\1
mime_type: text/plain
skip_files:
- ^(.*/)?.*\.yaml$
- ^(.*/)?.*\.sh$
- ^(.*/)?.*\.md$
#!/usr/bin/python -u
# coding=utf-8
"""
Generate certificates via Let's Encrypt
"""
import re
from subprocess import check_output, check_call
from os import path
import click
from colorama import Fore
import pexpect
# Extract the file/challenge from the LetsEncrypt output e.g.
CREX = re.compile(
".well-known\/acme-challenge\/(\S+) before continuing:\s+(\S+)",
re.MULTILINE
)
MODULE_CONFIG = 'module.yaml' # The file in our project root
APPENGINE_URL = ("https://console.cloud.google.com/" +
"appengine/settings/certificates")
def get_default_email():
"""Get a default user email from the git config."""
return check_output(['git', 'config', 'user.email']).strip()
@click.command()
@click.option('--appid', '-A', prompt=True)
@click.option('--test/--no-test', default=True)
@click.option('--domains', '-d', multiple=True)
@click.option('--app-path', default=path.abspath(path.dirname(__file__)))
@click.option('--acme-path', required=True)
@click.option('--email', default=get_default_email)
def gen(test, appid, domains, acme_path, app_path, email):
"""Regenerate the keys.
Run all the steps, being:
1. Call Let's Encrypt
2. Capture the challenges from the LE output
3. Deploy the AppEngine module
4. Print Cert. to terminal
"""
common_name = domains[0] # noqa
sans = " ".join(domains) # noqa
click.echo("""
APPID: {appid}
Test: {test}
Common Name: {common_name}
Domain(s): {sans}
App Path: {app_path}
ACME path: {acme_path}
User Email: {email}
""".format(**{
k: Fore.YELLOW + str(v) + Fore.RESET
for k, v in locals().items()
}))
CERT_PATH = acme_path
KEY_PATH = acme_path
CHAIN_PATH = acme_path
FULLCHAIN_PATH = acme_path
CONFIG_DIR = acme_path
WORK_DIR = path.join(acme_path, 'tmp')
LOG_DIR = path.join(acme_path, 'logs')
cmd = [
'letsencrypt',
'certonly',
'--rsa-key-size',
'2048',
'--manual',
'--agree-tos',
'--manual-public-ip-logging-ok',
'--text',
'--cert-path', CERT_PATH,
'--key-path', KEY_PATH,
'--chain-path', CHAIN_PATH,
'--fullchain-path', FULLCHAIN_PATH,
'--config-dir', CONFIG_DIR,
'--work-dir', WORK_DIR,
'--logs-dir', LOG_DIR,
'--email', email,
'--domain', ",".join(domains),
]
if test:
cmd.append('--staging')
print("$ " + Fore.MAGENTA + " ".join(cmd) + Fore.RESET)
le = pexpect.spawn(" ".join(cmd))
out = ''
idx = le.expect(["Press ENTER", "Select the appropriate number"])
if idx == 1:
# 1: Keep the existing certificate for now
# 2: Renew & replace the cert (limit ~5 per 7 days)
print le.before + le.after
le.interact("\r")
print "..."
le.sendline("")
if le.expect(["Press ENTER", pexpect.EOF]) == 1:
# EOF - User chose to not update certs.
return
out += le.before
# Hit "Enter" for each domain; we extract all challenges at the end;
# We stop just at the last "Enter to continue" so we can publish
# our challenges on AppEngine.
for i in xrange(len(domains) - 1):
le.sendline("")
le.expect("Press ENTER")
out += le.before
# The challenges will be in `out` in the form of CREX
challenges = CREX.findall(out)
if not challenges:
raise Exception("Expected challenges from the output")
for filename, challenge in challenges:
filepath = path.join(app_path, "challenges", filename)
print "[%s]\n\t%s\n\t=> %s" % (
Fore.BLUE + filepath + Fore.RESET,
Fore.GREEN + filename + Fore.RESET,
Fore.YELLOW + challenge + Fore.RESET
)
with open(filepath, 'w') as f:
f.write(challenge)
# Deploy to AppEngine
cmd = [
'appcfg.py',
'update',
'-A', appid,
path.join(app_path, MODULE_CONFIG)
]
print("$ " + Fore.MAGENTA + " ".join(cmd) + Fore.RESET)
check_call(cmd)
# After deployment, continue the Let's Encrypt (which has been waiting
# on the last domain)
le.sendline("")
le.expect(pexpect.EOF)
le.close()
if le.exitstatus:
print Fore.RED + "\nletsencrypt failure: " + Fore.RESET + le.before
return
print "\nletsencrypt complete.", le.before
# Convert the key to a format AppEngine can use
# LE seems to choose the domain at random, so we have to pluck it.
CPATH_REX = (
"Your certificate and chain have been saved at (.+)fullchain\.pem\."
)
outstr = le.before.replace("\n", "").replace('\r', '')
results = re.search(CPATH_REX, outstr, re.MULTILINE)
LIVE_PATH = "".join(results.group(1).split())
CHAIN_PATH = path.join(LIVE_PATH, "fullchain.pem")
PRIVKEY_PATH = path.join(LIVE_PATH, "privkey.pem")
cmd = [
'openssl', 'rsa',
'-in', PRIVKEY_PATH,
'-outform', 'pem',
'-inform', 'pem'
]
print "$ " + Fore.MAGENTA + " ".join(cmd) + Fore.RESET
priv_text = check_output(cmd)
with open(CHAIN_PATH, 'r') as cp:
pub_text = cp.read()
print """
--- Private Key ---
at {PRIVKEY_PATH}
(the above file must be converted with {cmd} to a format usable by
AppEngine, the result of which will be as follows)
{priv_text}
--- Public Key Chain ---
at {CHAIN_PATH}
{pub_text}
✄ Copy the above into the respective fields of AppEngine at
https://console.cloud.google.com/appengine/settings/certificates
""".format(
PRIVKEY_PATH=PRIVKEY_PATH,
priv_text=Fore.RED + priv_text + Fore.RESET,
CHAIN_PATH=CHAIN_PATH,
pub_text=Fore.BLUE + pub_text + Fore.RESET,
cmd=Fore.MAGENTA + " ".join(cmd) + Fore.RESET,
)
if __name__ == '__main__':
gen()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment