vhost_le/vh_le.py

483 lines
16 KiB
Python
Executable File

#!/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 "<ESCAPED_SPACE>"
'''
res = ''
curr_escape = False
curr_sep = None
for c in msg:
if curr_escape == True:
if c == ' ':
c = '<ESCAPED_SPACE>'
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 "<ESCAPED_SPACE>" by spaces
'''
return msg.replace('<ESCAPED_SPACE>', ' ')
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)