From fca07587b2c8ca84b1c5ec9b36b505c36ea6c71b Mon Sep 17 00:00:00 2001 From: Adel Daouzli Date: Mon, 20 Feb 2017 09:25:18 +0100 Subject: [PATCH] 1st commit: add vh_le.py script (mode test) with templates and conf files --- REAMDE.txt | 4 + acme_config_template | 15 ++ nginx_le_vhost_template | 17 ++ nginx_vhost_template | 46 +++++ vh_le.conf | 19 ++ vh_le.py | 438 ++++++++++++++++++++++++++++++++++++++++ vh_le_final.conf | 40 ++++ 7 files changed, 579 insertions(+) create mode 100644 REAMDE.txt create mode 100644 acme_config_template create mode 100644 nginx_le_vhost_template create mode 100644 nginx_vhost_template create mode 100644 vh_le.conf create mode 100755 vh_le.py create mode 100644 vh_le_final.conf diff --git a/REAMDE.txt b/REAMDE.txt new file mode 100644 index 0000000..a46670e --- /dev/null +++ b/REAMDE.txt @@ -0,0 +1,4 @@ +attention version de test ! +pour utiliser en prod, supprimer à la ligne 405: +"_final" +et remplacer vh_le.conf par vh_le_final.conf + diff --git a/acme_config_template b/acme_config_template new file mode 100644 index 0000000..bc05c63 --- /dev/null +++ b/acme_config_template @@ -0,0 +1,15 @@ +DOMAINS="{{ main_domain }}" +_UNAME="{{ second_domains }}" +_DIR="{{ le_certificate_folder }}/{{ main_domain }}" + +KEY="$_DIR/$_UNAME.key" +CRT="$_DIR/$_UNAME.crt" +CSR="$_DIR/$_UNAME.csr" +CHAINED="$_DIR/$_UNAME.chained" +INTERMEDIATE="$_DIR/$_UNAME.intermediate" +ACME_KEY="$_DIR/$_UNAME.acme.key" +ACME_CHALLENGE_ROOT=~acme/challenges +ACME_CHALLENGE_URL=/.well-known/acme-challenge +ACME_CHALLENGE_DIR="${ACME_CHALLENGE_ROOT}/${ACME_CHALLENGE_URL}" +RESTART_SERVICE=nginx + diff --git a/nginx_le_vhost_template b/nginx_le_vhost_template new file mode 100644 index 0000000..98de507 --- /dev/null +++ b/nginx_le_vhost_template @@ -0,0 +1,17 @@ +## +# Auto-generated configuration for Hadoly Virtual Hosts + + +server { + listen [::]:80; + server_name {{ second_domains }}; + + # configuration for Let's Encrypt ACME + location ~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root /var/lib/acme/challenges; + } + location = /.well-known/acme-challenge/ { + return 404; + } +} diff --git a/nginx_vhost_template b/nginx_vhost_template new file mode 100644 index 0000000..06e1ad4 --- /dev/null +++ b/nginx_vhost_template @@ -0,0 +1,46 @@ +# redirect http to https + +server { + listen [::]:80; + server_name {{ second_domains }}; + return 301 https://$server_name$request_uri; +} + +# conf for https + +server { + listen [::]:443; + server_name {{ second_domains }}; + + +# conf for LE ACME + location ~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root /var/lib/acme/challenges ; + } + location = /.well-known/acme-challenge/ { + return 404; + } + +# TLS conf + ssl on; + ssl_certificate /etc/nginx/sites/{{ main_domain }}/{{ main_domain }}.chained; + ssl_certificate_key /etc/nginx/sites/{{ main_domain }}/{{ main_domain }}.key; + ssl_session_timeout 5m; + ssl_prefer_server_ciphers on; + add_header Strict-Transport-Security max-age=2678400; + ssl_dhparam /etc/nginx/dh4096.pem; + ssl_session_cache shared:SSL:50m; + + +# reverse proxy + location / { + proxy_pass http://[{{ ip6_back }}]; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Ssl on; + } +} + + diff --git a/vh_le.conf b/vh_le.conf new file mode 100644 index 0000000..96a60a0 --- /dev/null +++ b/vh_le.conf @@ -0,0 +1,19 @@ +domain_main = "www.hadoly.fr" + +tld = ['fr', 'org'] +name = "hadoly" +sub = ['www'] + +ip6_back = "2001:0912:3064:XXXX" + +main_zone = "merlin.hadoly" + +template_nginx_vhost = "./nginx_vhost_template" +template_le_nginx_vhost = "./nginx_le_vhost_template" +template_acme_conf = "./acme_config_template" +available_path = "./available" +enabled_path = "./enabled" + +le_certificate_folder = "./sites" +acme_folder = "./acme" + diff --git a/vh_le.py b/vh_le.py new file mode 100755 index 0000000..d6744dc --- /dev/null +++ b/vh_le.py @@ -0,0 +1,438 @@ +#!/usr/bin/python + +import os +import sys +import subprocess +from jinja2 import Template +import codecs +import argparse + + +__version__ = "0.1" +__author__ = "Adel Daouzli" +__licence__ = "GPL3" + + +g_config_file = "./vh_le.conf" + +global g_verbosity +g_verbosity = False + + +# parse command line arguments + +parser = argparse.ArgumentParser(description="Deploy a DNS configuration with Let's Encrypt configuration. Each provided option will override the config file options.") + +parser.add_argument('-l', '--ls-steps', help='show the list of available steps', + action="store_true") +parser.add_argument('-s', '--step', dest='step', help='run a given step (e.g. 3)') +parser.add_argument('-f', '--from-step', dest='from_step', + help='run from a given step to the end (e.g. 3)') +parser.add_argument('-v', '--verbose', help='If set will print more informations', + action="store_true") + +parser.add_argument('-c', '--config-file', dest='config_file', + help='the configuration file of the script (default ./vh_le.conf)') + +parser.add_argument('-d', '--main-domain', dest='main_domain', + help='the main domain (e.g. www.hadoly.fr)') +parser.add_argument('-t', '--tld', dest='tld', + help='list of TLDs used to determine the secondary domains (e.g. ["fr", "org"])') +parser.add_argument('-b', '--sub', dest='sub', + help='list of subdomains used to determine the secondary domains (e.g. ["www"])') +parser.add_argument('-n', '--name', dest='name', + help="domain's base name used to determine the secondary domains (e.g. hadoly)") +parser.add_argument('-z', '--main-zone', dest='main_zone', + help="the main zone record without extension (e.g. merlin.hadoly)") +parser.add_argument('-i', '--ip6-back', dest='ip6_back', + help="the IPv6 (e.g. 2001:912:3064:131::1:)") + +parser.add_argument('--certif-folder', dest='certif_folder', + help="path for Let'sEncrypt certificate (default /etc/nginx/sites)") +parser.add_argument('--acme-folder', dest='acme_folder', + help="path for ACME configuration (default /etc/acme)") + +parser.add_argument('--template-vhost', dest='template_vhost', + help="the template Vhost file (default ./nginx_vhost_template)") +parser.add_argument('--template-le-vhost', dest='template_le_vhost', + help="the temporary template Vhost file for Let's Encrypt (default ./nginx_le_vhost_template)") +parser.add_argument('--template-acme', dest='template_acme', + help="the template file for ACME config (default ./acme_config_template)") + +parser.add_argument('--sites-available', dest='sites_available', + help="path to sites-available (default /etc/nginx/sites-available)") +parser.add_argument('--sites-enabled', dest='sites_available', + help="path to sites-enabled (default /etc/nginx/sites-enabled)") + +args = parser.parse_args() + + +#g_tld = ['fr', 'org'] +#g_name = "hadoly" +#g_sub = ['www'] +#g_domain_main = "www.hadoly.fr" + +#g_ip6_back = "2001:0912:3064:XXXX" + +#g_main_zone = "merlin.hadoly.fr" + +#g_template_nginx_vhost = "./nginx_vhost_template" +#g_template_le_nginx_vhost = "./nginx_le_vhost_template" +#g_template_acme_conf = "./acme_config_template" +#g_available_path = "./available" # "/etc/nginx/sites-available" +#g_enabled_path = "./enabled" # "/etc/nginx/sites-enabled" + +#g_le_certificate_folder = "./sites" # "/etc/nginx/sites" +#g_acme_folder = "./acme" # "/etc/acme" + + + +############# Functions + +def print_info(msg): + print(msg) + +def print_debug(msg): + if g_verbosity: + print("DEBUG: " + str(msg)) + +def print_error(msg): + print("ERROR: " + str(msg)) + + +def _interpret(value): + """Interpret a value as a string (with " or ') or a list + + :param value: the value to interpret (should be a string) + :type: str + :return: string or a list + """ + if type(value) is str: + value = value.strip() + # string + if (value.startswith("'") and value.endswith("'")) or \ + (value.startswith('"') and value.endswith('"')): + value = value[1:-1] + # list + elif value.startswith("[") and value.endswith("]"): + value = [_interpret(e) for e in value[1:-1].split(",")] + return value + +def get_config_from_file(filename): + """Read the parameters from a configuration file + + :param filename: the configuration file name + :return: a dictionary with the configuration + """ + config = {} + with open(filename) as f: + print_debug("Get config from file '{}'".format(filename)) + for line in f.readlines(): + line = line.strip() + # ignore empty lines and comments + if line == "" or line.startswith("#"): + continue + param, value = line.split("=", 1) + param, value = param.strip(), value.strip() + value = _interpret(value) + config[param] = value + return config + + +def shell_command(cmd, get_stderr=False, **kwargs): + """Run a shell command + + :param cmd: the command with parameters separated by spaces + :param get_stderr: if True will get also stderr + :return: stdout or (stdout, stderr) if get_stderr or None if failed + """ + ret = None + try: + if get_stderr: + ret = subprocess.check_output(cmd.split(' '), **kwargs) + else: + ret = subprocess.check_output(cmd.split(' '), stderr=subprocess.PIPE, **kwargs) + print_debug("run command '{}'".format(cmd)) + except Exception as e: + print_debug("failed to run command: {}\n got exception: <{}>".format(cmd, e)) + return ret + + +def check_dns_record(domain, expected=None, record_type="CNAME", resolver=None): + """Check if a DNS record is correct for a domain + + :param domain: the domain name to check (e.g. "www.hadoly.fr") + :param expected: the expected record in a tuple base name and TLDs + (e.g ("merlin.hadoly", ["fr", "org"])) or a string. If None will just + expect a record (default None) + :param record_type: the record type (default "CNAME") + :param resolver: a resolver to use. If None will use the local system one + (default None) + :return: True if success else False + """ + ret = False + if resolver: + res = shell_command("dig +short {} {} @{}".format(record_type, domain, resolver)) + else: + res = shell_command("dig +short {} {}".format(record_type, domain)) + if type(expected) is str and type(res) is str and res.startswith(expected): + ret = True + elif type(expected) is tuple and type(res) is str: + base, tlds = expected + tlds = [tlds] if type(tlds) is str else tlds + for tld in tlds: + tld = tld[1:] if tld[0] == '.' else tld + if res.startswith(base + "." + tld): + print_debug("found DNS record : {}".format(res)) + ret = True + break + elif expected is None and res: + ret = True + return ret + + +def generate_file(template_file, output_file, data): + """Generate a file based on a template file + + :param template_file: the input template file + :param output_file: the output file to generate + :param data: the data to provide to the template + :type data: dict + :return: True if success else False + """ + ret = False + #print_debug("generate_file(template_file={}, output_file={}, data={})".format(template_file, output_file, data)) + with open(template_file) as f: + print_debug("Use template '{}'".format(template_file)) + d=f.read() + template = Template(d) + content = template.render(**data) + with codecs.open(output_file, "w", 'utf-8') as fo: + fo.write(content) + ret = True + print_debug("file '{}' generated !".format(output_file)) + return ret + + + +################################################################################ +################################################################################ +################################################################################ +################################################################################ + + +### list steps and stop + +steps = ''' +STEP 1 : DNS records +STEP 2 : Create Nginx available configuration +STEP 3 : Create sites-enabled Nginx link +STEP 4 : Reload Nginx +STEP 5 : create Nginx folder that will receive the Let's Encrypt certificate +STEP 6 : create the acme configuration file +STEP 7 : acme create +STEP 8 : acme renew +STEP 9 : update the Nginx enable final configuration file +STEP 10 : reload Nginx +''' +if args.ls_steps: + print (steps) + sys.exit(0) + + + +############### Prepare config + + + +# read configuration from file + +if args.config_file: + conf_file = args.config_file +else: + conf_file = g_config_file + +c = get_config_from_file(conf_file) + + +# overwrite configuration with command line args + +if args.verbose: + g_verbosity = True + +if args.main_domain: + c['domain_main'] = args.main_domain +if args.tld: + c['tld'] = args.tld +if args.sub: + c['sub'] = args.sub +if args.name: + c['name'] = args.name +if args.ip6_back: + c['ip6_back'] = args.ip6_back +if args.main_zone: + c['main_zone'] = args.main_zone + +if args.certif_folder: + c['le_certificate_folder'] = args.certif_folder +if args.acme_folder: + c['acme_folder'] = args.acme_folder + +if args.template_vhost: + c['template_nginx_vhost'] = args.template_vhost +if args.template_le_vhost: + c['template_le_nginx_vhost'] = args.template_le_vhost +if args.template_acme: + c['template_acme_conf'] = args.template_acme +if args.sites_available: + c['available_path'] = args.sites_available +if args.sites_available: + c['enabled_path'] = args.sites_available + + +# build secondary domains + +domains = [] +for tld in c['tld']: + domains.append(c['name'] + '.' + tld) + for sub in c['sub']: + domains.append(sub + '.' + c['name'] + '.' + tld) + +# determine the steps to run + +if args.step: + step = {i+1: False for i in range(10)} + step[int(args.step)] = True +elif args.from_step: + step = {i+1: False for i in range(10)} + for i in range(11 - int(args.from_step)): + step[i+int(args.from_step)] = True +else: + step = {i+1: True for i in range(10)} + + +############# Steps + + +##### DNS records (CNAME) +if step[1]: + print_info("\n++++++ STEP 1 : DNS records ++++++") + + # check each domain for DNS record + no_dns_record = [] + for domain in [c['domain_main']] + domains: + res = check_dns_record(domain, expected=(c['main_zone'], c['tld'])) + if not res: + no_dns_record.append(domain) + print_info("warning: No DNS record for '{domain}'".format(domain=domain)) + + # check if main domain had expected record else stop + if c['domain_main'] in no_dns_record: + msg = """No DNS record for '{domain}'. Please record it before + running this script. You may add in your DNS zone something like: + {domain} CNAME {entry} + """.format(domain=c['domain_main'], entry=c['main_zone'] + '.' + tld[0]) + print_error(msg) + sys.exit(1) + print_info("Valid DNS record ({} -> {})".format(c['domain_main'], c['main_zone'])) + + + +##### Create Nginx available configuration + +if step[2]: + print_info("\n++++++ STEP 2 : Create Nginx available configuration ++++++") + + shell_command("sudo mkdir -p {} 2>/dev/null".format(c['available_path'])) + if generate_file(c['template_le_nginx_vhost'], + c['available_path'] + "/" + c['domain_main'], + {'second_domains': ' '.join(domains)}) == False: + print_error("Failed to generate temporary Nginx VHost configuration file for LE") + sys.exit(1) + + +##### Create sites-enabled Nginx link + +if step[3]: + print_info("\n++++++ STEP 3 : Create sites-enabled Nginx link ++++++") + + shell_command("ln -s {} {}".format(os.path.abspath(c['available_path'] + "/" + c['domain_main']), + c['domain_main']), + cwd=c['enabled_path']) + +##### Reload Nginx +# Note: after that if a server on internet request on +# http://$domaine/.well-known/acme-challenge/ it will be challenged + +if step[4]: + print_info("\n++++++ STEP 4 : Reload Nginx ++++++") + + shell_command("sudo service nginx restart", get_stderr=True) + + +##### create Nginx folder that will receive the Let's Encrypt certificate + +if step[5]: + print_info("\n++++++ STEP 5 : create Nginx folder that will receive the Let's Encrypt certificate ++++++") + + shell_command("sudo mkdir -p {}/{}".format(c['le_certificate_folder'], + c['domain_main'])) + +##### create the acme configuration file + +if step[6]: + print_info("\n++++++ STEP 6 : create the acme configuration file ++++++") + + shell_command("sudo mkdir -p {} 2>/dev/null".format(c['acme_folder'])) + config_file = "{}/{}.conf".format(c['acme_folder'], c['domain_main']) + if generate_file(c['template_acme_conf'], config_file, + {"main_domain": c['domain_main'], + "le_certificate_folder": c['le_certificate_folder'], + "second_domains": ' '.join(domains)}) == False: + print_error("Failed to generate ACME configuration file") + sys.exit(1) + + +##### acme create + +if step[7]: + print_info("\n++++++ STEP 7 : acme create ++++++") + + shell_command("sudo mkdir -p {} 2>/dev/null".format(c['acme_folder'])) + cmd = "sudo /usr/local/bin/acme_create --config {}/{}.conf" + shell_command(cmd.format(c['acme_folder'], c['domain_main']), get_stderr=True) + + +##### acme renew + +if step[8]: + print_info("\n++++++ STEP 8 : acme renew ++++++") + + shell_command("sudo mkdir -p {} 2>/dev/null".format(c['acme_folder'])) + cmd = "sudo -u acme /usr/local/bin/acme_renew --config {}/{}.conf" + shell_command(cmd.format(c['acme_folder'], c['domain_main']), get_stderr=True) + + +##### update the Nginx enable final configuration file + +if step[9]: + print_info("\n++++++ STEP 9 : update the Nginx enable final configuration file ++++++") + + if generate_file(c['template_nginx_vhost'], + c['available_path'] + "/" + c['domain_main']+"_final", #TODO remove _final + {"main_domain": c['domain_main'], + "second_domains": ' '.join(domains), + "ip6_back": c['ip6_back']}) == False: + print_error("Failed to generate Nginx VHost configuration file") + sys.exit(1) + + +##### reload Nginx + +if step[10]: + print_info("\n++++++ STEP 10 : reload Nginx ++++++") + + shell_command("sudo service nginx restart", get_stderr=True) + + + diff --git a/vh_le_final.conf b/vh_le_final.conf new file mode 100644 index 0000000..d8f9729 --- /dev/null +++ b/vh_le_final.conf @@ -0,0 +1,40 @@ +# the main domain +domain_main = "www.hadoly.fr" + +# list of TLDs used to determine the secondary domains +tld = ['fr', 'org'] +# domain's base name used to determine the secondary domains +name = "hadoly" +# list of subdomains used to determine the secondary domains +sub = ['www'] + +# the IPv6 +ip6_back = "2001:0912:3064:XXXX" + +# the main zone record without extension +main_zone = "merlin.hadoly" + + +# path for Let'sEncrypt certificate +le_certificate_folder = "/etc/nginx/sites" + +# path for ACME configuration +acme_folder = "/etc/acme" + + +# the template Vhost file +template_nginx_vhost = "./nginx_vhost_template" + +# the temporary template Vhost file for Let's Encrypt +template_le_nginx_vhost = "./nginx_le_vhost_template" + +# the template file for ACME config +template_acme_conf = "./acme_config_template" + + +# path to sites-available +available_path = "/etc/nginx/sites-available" + +# path to sites-enabled +enabled_path = "/etc/nginx/sites-enabled" +