#!/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('-o', '--not-only-sub-domains', help='If set will considere main domain and sub-domains (default considere only sub-domains)', 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 _escape_quoted_spaces(msg): '''Replace all spaces comprised between single or double quotes by the string "" ''' res = '' curr_escape = False curr_sep = None for c in msg: if curr_escape == True: if c == ' ': c = '' if c == curr_sep: curr_escape = False curr_sep = None c = '' else: if c in ['"', "'"]: curr_sep = c curr_escape = True c = '' res += c return res def _replace_quoted_spaces(msg): '''Replace all occurrences of the string "" by spaces ''' return msg.replace('', ' ') def _split_keeping_escaped(msg): '''Split a string base on spaces but keep strings with spaces comprised between single or double quotes. ''' res = _escape_quoted_spaces(msg).split(' ') return [_replace_quoted_spaces(e) for e in res] 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(_split_keeping_escaped(cmd), **kwargs) else: ret = subprocess.check_output(_split_keeping_escaped(cmd), 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 ################################################################################ ################################################################################ ################################################################################ ################################################################################ if args.verbose: g_verbosity = True ### 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.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 only_sub_domains = True if args.not_only_sub_domains: only_sub_domains = False domains = [] for tld in c['tld']: if not only_sub_domains: 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, {"uname": c['domain_main'], "le_certificate_folder": c['le_certificate_folder'], "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'], {"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)