|
|
|
@ -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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|