diff --git a/.gitignore b/.gitignore index 0c125f9..dac5c63 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ __pycache__ clusters.json /nginx /autossl -.env \ No newline at end of file +.env +ncc +/venv diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4333293 --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +DIR=$(mktemp -d) +pip install -r requirements.txt --target="$DIR" + +cp -r nginx_configurator "$DIR" + +python3 -m zipapp -p "/bin/python3" -m "nginx_configurator.main:cli" -o ncc "$DIR" + +rm -r "$DIR" diff --git a/n_gen.py b/n_gen.py deleted file mode 100755 index d6d89e7..0000000 --- a/n_gen.py +++ /dev/null @@ -1,166 +0,0 @@ -import os -import json -import pyinputplus as pyip -from jinja2 import Environment, PackageLoader, select_autoescape -from dotenv import load_dotenv -import n_ssl - -# Get clusters from json config -with open("clusters.json") as json_file: - CLUSTERS = json.load(json_file)["clusters"] - -# Setup Jinja2 -jin = Environment(loader=PackageLoader("n_gen"), autoescape=select_autoescape()) - -load_dotenv() -NGINX_DIR = os.getenv("NGINX_DIR") - - -# Go through config names and find highest config number. Increment by 1 and use as new ID -def get_conf_id(nginx_dir): - list_auto = os.listdir(nginx_dir + "/sites/auto") - list_custom = os.listdir(nginx_dir + "/sites/custom") - domain_list = list_auto + list_custom - last_id = 0 - for dom in domain_list: - id = int(dom.split("-")[0]) - if id > last_id: - last_id = id - new_id = last_id + 1 - return new_id - - -def get_domains(): - new_domain = True - domains = [] - while new_domain: - domain = pyip.inputStr("Enter a full domain name: ") - domains.append(domain) - next_domain = pyip.inputYesNo("Do you want to add another domain? (y/n) ") - if next_domain == "no": - new_domain = False - return domains - - -def get_upstreams(clusters): - print("\nNow, we will select upstream server(s).") - if ( - pyip.inputYesNo( - "Is the service located on existing upstream cluster (like Swarm)? (y/n) " - ) - == "yes" - ): - cluster_list = [d["name"] for d in clusters] - sel_cluster_name = pyip.inputMenu(cluster_list, lettered=True, blank=True) - cluster = [ - element for element in clusters if element["name"] == sel_cluster_name - ][0] - print("Selected cluster " + cluster["name"] + " with nodes:") - for node in cluster["nodes"]: - print(node) - return cluster["nodes"] - else: - new_upstream = True - upstreams = [] - while new_upstream: - upstream = pyip.inputStr("Enter IPv4 address of one upstream server: ") - upstreams.append(upstream) - next_upstream = pyip.inputYesNo( - "Do you want to add another upstream server? (y/n) " - ) - if next_upstream == "no": - new_upstream = False - return upstreams - - -def get_port(): - return pyip.inputInt( - "\nEnter a port number for the upstream servers: ", min=81, max=65534 - ) - - -def get_proto(): - print("\nEnter the upstream protocol (between service and reverse proxy)") - return pyip.inputMenu(["http://", "https://"], lettered=True) - - -def input_check(domains, upstreams, port, proto): - print("\n-----------------------------------------------") - print("You have entered following service information:") - print("Domains:") - for domain in domains: - print("\t" + domain) - - print("Upstream servers with proto and port:") - for upstream in upstreams: - print("\t" + proto + upstream + ":" + str(port)) - - if pyip.inputYesNo("Is this information correct? (y/n) ") == "yes": - return True - else: - print("Sorry to hear that, please start again. Exiting") - exit() - - -def create_nginx_config(id, domains, upstreams, port, proto): - template = jin.get_template("nginx-site.conf") - return template.render( - id=id, domains=domains, upstreams=upstreams, port=port, proto=proto - ) - - -def write_nginx_config(config, nginx_dir, domains, conf_id): - filename = str(conf_id) + "-" + domains[0] + ".conf" - path = nginx_dir + "/sites/auto/" + filename - with open(path, "w") as conf_file: - conf_file.write(config) - - -def create_ssl_config(conf_id): - template = jin.get_template("ssl.conf") - return template.render(id=conf_id) - - -def write_ssl_config(config, conf_id, nginx_dir): - filename = str(conf_id) + ".conf" - path = nginx_dir + "/ssl/" + filename - with open(path, "w") as conf_file: - conf_file.write(config) - - -def ssl_continue(): - if ( - pyip.inputYesNo( - "Do you want to prepare ssl certs and replicate the config? (y/n) " - ) - == "yes" - ): - n_ssl.main() - else: - print("Ok, you can run n_ssl.py to do it later.") - exit() - - -def main(): - print("This script will generate nginx configuration and for new service.\n") - conf_id = get_conf_id(NGINX_DIR) - domains = get_domains() - upstreams = get_upstreams(CLUSTERS) - port = get_port() - proto = get_proto() - input_check(domains, upstreams, port, proto) - nginx_config = create_nginx_config(conf_id, domains, upstreams, port, proto) - write_nginx_config(nginx_config, NGINX_DIR, domains, conf_id) - - ssl_config = create_ssl_config(conf_id) - write_ssl_config(ssl_config, conf_id, NGINX_DIR) - - print("Nginx config created.") - ssl_continue() - - -# def test(): -# print(create_nginx_config("1110", ['nolog.cz', 'www.nolog.cz'], ['10.0.0.1', '10.0.0.2'], 80, 'https://')) - -if __name__ == "__main__": - main() diff --git a/n_ssl.py b/n_ssl.py deleted file mode 100644 index 3ab64c1..0000000 --- a/n_ssl.py +++ /dev/null @@ -1,124 +0,0 @@ -import os -import subprocess -import re -import sysrsync -from dotenv import load_dotenv - -# NGINX_DIR="/etc/nginx" -# DOMAINS_TXT = "/etc/autossl/domains.txt" -# DEHYDRATED_LOC = "/etc/autossl/dehydrated.sh" - -load_dotenv() -NGINX_DIR = os.getenv("NGINX_DIR") -DOMAINS_TXT = os.getenv("DOMAINS_TXT") -DEHYDRATED_LOC = os.getenv("DEHYDRATED_LOC") -REMOTE = os.getenv("REMOTE") -REMOTE_SSH_KEY = os.getenv("REMOTE_SSH_KEY") - - -def create_domfile(): - # Get nginx config files with "# AUTOSSL" tag, parse IDs and domains and create domains.txt file for Dehydrated - sites_path = NGINX_DIR + "/sites" - # It's probably not the best to use grep here, but it's really fast unlike reading files in Python directly. But what can go wrong? (lol) - grep_out = subprocess.run( - ["grep", "-Rh", "AUTOSSL", sites_path], capture_output=True, text=True - ) - if grep_out.returncode == 0: - DOMAIN_LINES = [] - for line in grep_out.stdout.splitlines(): - id = re.findall(r"\d+", line)[-1] - domains = re.findall(r"(?<=server_name )(.*)(?=;)", line)[0] - DOMAIN_LINES.append(domains + " > " + str(id)) - - if len(DOMAIN_LINES) > 0: - with open(DOMAINS_TXT, "w") as fp: - for line in DOMAIN_LINES: - # write each item on a new line - fp.write("%s\n" % line) - else: - print("No data to write to domains.txt. \n Aborting") - exit() - else: - print("Finding #AUTOSSL comments in nginx configs failed.") - exit() - - -def request_cert(): - print("Requesting certificate") - dehydrated_run = subprocess.run( - [DEHYDRATED_LOC, "-c"], capture_output=True, text=True - ) - if dehydrated_run.returncode != 0: - print("Something went wrong with dehydrated.sh") - print(dehydrated_run.stdout) - else: - print( - "Certificates are successfully dehydrated. (It went OK and cert is now generated)" - ) - - -def reload_local_nginx(): - nginx_check = subprocess.run(["nginx", "-t"], capture_output=True, text=True) - if nginx_check.returncode != 0: - print("nginx config is not valid! Aborting") - print(nginx_check.stdout) - exit() - - nginx_reload = subprocess.run( - ["systemctl", "reload", "nginx.service"], capture_output=True, text=True - ) - if nginx_reload.returncode != 0: - print("Nginx reload returned non-zero status code") - print(nginx_reload.stdout) - exit() - - -def remote_replication(remote, ssh_key): - # Copy nginx config to second server - sysrsync.run( - source="/etc/nginx/", - destination="/etc/nginx/", - destination_ssh=remote, - private_key=ssh_key, - options=["-a", "--delete"], - ) - # Copy certificates to second server - sysrsync.run( - source="/etc/autossl/", - destination="/etc/autossl/", - destination_ssh=remote, - private_key=ssh_key, - options=["-a", "--delete"], - ) - - -def remote_reload(remote, ssh_key): - # Check and reload nginx on second server - nginx_check = subprocess.run( - ["ssh", "-i", ssh_key, remote, "nginx", "-t"], capture_output=True, text=True - ) - if nginx_check.returncode != 0: - print("Remote nginx config is not valid! Please check manually.") - print(nginx_check.stdout) - return False - else: - nginx_reload = subprocess.run( - ["ssh", "-i", ssh_key, remote, "systemctl", "reload", "nginx.service"], - capture_output=True, - text=True, - ) - if nginx_reload.returncode != 0: - print("Remote nginx reload failed, please check manually.") - print(nginx_reload.stdout) - - -def main(): - create_domfile() - request_cert() - reload_local_nginx() - remote_replication(REMOTE, REMOTE_SSH_KEY) - remote_reload(REMOTE, REMOTE_SSH_KEY) - - -if __name__ == "__main__": - main() diff --git a/new_service.sh b/new_service.sh deleted file mode 100644 index e989a62..0000000 --- a/new_service.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -kill -s $(keepalived --signum=DATA) $(cat /var/run/keepalived.pid) -STATE=$(cat /tmp/keepalived.data |grep MASTER) - -if [[ -z "${STATE}" ]]; then - echo "This is a secondary backup server, run the script on current master" -else - python3 n_gen.py -fi \ No newline at end of file diff --git a/nginx_configurator/__init__.py b/nginx_configurator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nginx_configurator/certs.py b/nginx_configurator/certs.py new file mode 100644 index 0000000..264a4f9 --- /dev/null +++ b/nginx_configurator/certs.py @@ -0,0 +1,85 @@ +import os +import re +from pathlib import Path +from typing import List +from .templating import jinja +import glob + +DOMAINS_RE = re.compile( + r"^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?([a-z0-9][a-z0-9\-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$" +) + + +def _walk_nginx_conf(nginx_conf: Path): + """Recursively finds all configuration files of a nginx config""" + + # this path is not necessarily correct... someone could have a weird prefix and + # conf file combination + conf_dir = nginx_conf.parent + + stack = [nginx_conf] + + while len(stack): + file = stack.pop() + conf = file.read_text() + + yield file, conf + + for include in re.finditer(r"(?:^|\n\s*|[{;]\s*)include (.+);", conf): + pattern = include.group(1) + for file in glob.glob( + pattern if pattern.startswith("/") else f"{conf_dir}/pattern" + ): + stack.append(Path(file)) + + +def gather_autossl_directives(nginx_conf: Path): + """ + Finds #AUTOSSL directives inside an nginx configuration. The server_name + must be on a separate line. (which it usually is) + """ + directives = [] + for _, conf in _walk_nginx_conf(nginx_conf): + for directive in re.finditer( + r"(?:^|\n\s*|[{;]\s*)server_name (.*); *# *AUTOSSL *> *(\S+)", conf + ): + domains, alias = directive.groups() + domains = domains.split() + + if any(not re.match(DOMAINS_RE, domain) for domain in domains): + raise ValueError( + f"Cannot get SSL cert for \"{''.join(domains)}\". Invalid domains." + ) + + if not re.match(r"^[a-zA-Z0-9-_]$", alias): + raise ValueError(f'Invalid cert alias "{alias}"') + + directives.append((domains, alias)) + + return directives + + +def get_site_files(nginx_dir: Path) -> List[Path]: + names = [] + for file in (nginx_dir / "sites/auto").iterdir(): + if file.is_file() and re.match(r"\d+-.+\.conf", file.name): + names.append(file) + for file in (nginx_dir / "sites/custom").iterdir(): + if file.is_file() and re.match(r"\d+-.+\.conf", file.name): + names.append(file) + + return names + + +def build_domains_txt(directives): + return ( + "\n".join(" ".join(domains) + " > " + alias for domains, alias in directives) + + "\n" + ) + + +def generate_ssl_configs(dir: Path, cert_aliases: List[str]): + for alias in cert_aliases: + file = dir / (alias + ".conf") + template = jinja.get_template("ssl.conf") + file.write_text(template.render(alias=alias)) diff --git a/nginx_configurator/main.py b/nginx_configurator/main.py new file mode 100644 index 0000000..3ae203a --- /dev/null +++ b/nginx_configurator/main.py @@ -0,0 +1,364 @@ +import json +import os +import re +import tempfile +from pathlib import Path +import click +from dotenv import load_dotenv +from . import sysaction, certs +from .sysaction import quit_on_err +from .templating import jinja + +load_dotenv(os.getenv("DOTENV_PATH", "/etc/ncc/.env")) + +NGINX_DIR = Path(os.getenv("NGINX_DIR", "/etc/nginx")) +DOMAINS_TXT = Path(os.getenv("DOMAINS_TXT", "/etc/dehydrated/domains.txt")) +REMOTE = os.getenv("REMOTE") +REMOTE_SSH_KEY = os.getenv("REMOTE_SSH_KEY") +DEHYDRATED_LOC = os.getenv("DEHYDRATED_LOC", "/etc/dehydrated/dehydrated.sh") +DEHYDRATED_TRIGGER_FILE = Path( + os.getenv("DEHYDRATED_TRIGGER_FILE", "/etc/dehydrated/trigger") +) +CLUSTERS_FILE = Path(os.getenv("CLUSTERS_FILE", "/etc/ncc/clusters.json")) + + +@click.group() +@click.option("--skip-master-check", type=bool, is_flag=True) +def cli(skip_master_check: bool): + """Update the nginx cluster configuration + + MUST BE RAN ON MASTER (will detect automatically) + """ + + if not skip_master_check and not sysaction.is_keepalived_master(): + click.echo("Refusing to start. Not running on master.", err=True) + exit(1) + + +@cli.command() +def reload(): + """Replicate the local config and reload the nginx cluster + + Does the following: + 1. checks the nginx configuration + 2. generates domains.txt for dehydrated + 3. runs dehydrated to obtain certificates + 4. reloads nginx + 5. replicates the validated configuration + """ + + # check nginx config + quit_on_err( + sysaction.check_nginx_config(NGINX_DIR), + print_always=True, + additional_info="Nginx configuration is incorrect", + ) + + # build domains.txt + try: + directives = certs.gather_autossl_directives(NGINX_DIR / "nginx.conf") + + DOMAINS_TXT.write_text(certs.build_domains_txt(directives)) + except ValueError as e: + click.secho(e, err=True, fg="red") + exit(1) + + # obtain certs + quit_on_err( + sysaction.run_dehydrated(DEHYDRATED_LOC), + additional_info="Failed to run dehydrated", + ) + certs.generate_ssl_configs(NGINX_DIR / "ssl", [d[1] for d in directives]) + + # reload nginx + quit_on_err(sysaction.reload_nginx(), additional_info="Failed to reload nginx") + + # replicate to remote + sysaction.remote_replication(REMOTE, REMOTE_SSH_KEY) + quit_on_err( + sysaction.remote_check_nginx_config(REMOTE, REMOTE_SSH_KEY, NGINX_DIR), + additional_info="Remote nginx configuration is incorrect", + ) + quit_on_err( + sysaction.remote_reload_nginx(REMOTE, REMOTE_SSH_KEY), + additional_info="Remote nginx failed to reload", + ) + + +def get_upstreams(): + clusters = json.loads(Path(CLUSTERS_FILE).read_text()) + click.echo("Existing clusters:") + line = "" + for cluster in clusters: + if len(line + " " + cluster["name"]) > 78: + click.echo(" " + line.strip()) + line = cluster["name"] + else: + line += " " + cluster["name"] + click.echo(" " + line.strip()) + + upstreams = click.prompt("Comma-separated upstreams, or an existing cluster name") + + cluster = next((c for c in clusters if c["name"] == upstreams), None) + if cluster: + upstreams = cluster["upstreams"] + else: + upstreams = [u.strip() for u in upstreams.split(",")] + + return upstreams + + +def get_id(): + return max(int(n.name.split("-")[0]) for n in certs.get_site_files(NGINX_DIR)) + 1 + + +def test_config(config: str, file: Path): + old_config = None + if file.exists(): + old_config = file.read_text() + + if config: + file.write_text(config) + else: + file.unlink(missing_ok=True) + + c, out = sysaction.check_nginx_config(NGINX_DIR) + if c: + try: + certs.gather_autossl_directives(NGINX_DIR / "nginx.conf") + except ValueError as e: + out = e + c = False + + # rollback + if old_config is not None: + file.write_text(old_config) + else: + file.unlink() + + return c, out + + +def edit_service(config, service_file: Path): + ok = False + + new_config = None + while not ok: + new_config = click.edit(config, extension=".conf") + + if new_config is not None: + config = new_config + + c, out = test_config(config, service_file) + + if not c: + click.echo(out, err=True) + click.secho("Failed to verify configuration", fg="red") + + choice = click.prompt( + "Edit service configuration?", type=click.Choice(("yes", "abort")) + ) + if choice == "abort": + tmp = Path(tempfile.mktemp()) + tmp.write_text(config) + click.echo(f"Unfinished service written to {tmp}") + break + + ok = c + + return new_config, ok + + +@cli.command() +@click.pass_context +def new(ctx): + """Create a new service""" + config = "" + should_open_editor = False + id = get_id() + + if click.confirm("Would you like to use the default template?", default=True): + template = jinja.get_template("nginx-site.conf") + + domains = [ + d.strip() for d in click.prompt("Comma-separated domains").split(",") + ] + upstreams = get_upstreams() + port = click.prompt("Upstream port", type=int) + proto = ( + click.prompt( + "Upstream protocol", + type=click.Choice(("http", "https")), + show_choices=True, + ) + + "://" + ) + + config = template.render( + id=id, domains=domains, upstreams=upstreams, port=port, proto=proto + ) + # create ssl file so configuration test passes + Path(f"/etc/nginx/ssl/{id}.conf").touch() + + filename = f"{id}-{domains[0]}.conf" + else: + should_open_editor = True # force open the config + template = jinja.get_template("nginx-minimal.conf") + config = template.render(id=id) + filename = f'{id}-{click.prompt("Service name (used as filename (eg. 01-service.conf))")}.conf' + + # XXX: beware of weird code below + + should_open_editor = should_open_editor or click.confirm( + "Would you like to edit the config?", default=True + ) + + # assume the user wants to edit the file if we are opening the editor + service_file = Path( + NGINX_DIR / "sites" / ("custom" if should_open_editor else "auto") / filename + ) + + ok = False + while not ok: + if should_open_editor: + new_cfg, ok = edit_service(config, service_file) + + if not ok: + break + if new_cfg: + config = new_cfg + else: + # correct previous assumption + service_file = NGINX_DIR / "sites/auto" / filename + else: + ok, out = test_config(config, service_file) + if not ok: + click.echo(out, err=True) + click.secho("Failed to verify nginx configuration", fg="red") + choice = click.prompt( + "Edit service configuration?", type=click.Choice(("yes", "abort")) + ) + if choice == "abort": + break + should_open_editor = True + service_file = NGINX_DIR / "sites/custom" / filename + + if not ok: + Path(f"/etc/nginx/ssl/{id}.conf").unlink(missing_ok=True) + exit(1) + + service_file.write_text(config) + + ctx.invoke(reload) + + +@cli.command() +@click.argument("service") +@click.pass_context +def edit(ctx, service: str): + """Edit a service""" + filename = service if service.endswith(".conf") else service + ".conf" + auto = True + + file = NGINX_DIR / "sites/auto" / filename + if not file.exists(): + auto = False + file = NGINX_DIR / "sites/custom" / filename + + if not file.exists(): + click.secho(f"Service {filename} does not exist", fg="red") + exit(1) + + config = file.read_text() + new_cfg, ok = edit_service(config, file) + + if not new_cfg: + exit(0) + + if not ok: + exit(1) + + if auto: + file = file.rename(NGINX_DIR / "sites/custom" / filename) + + file.write_text(new_cfg) + + ctx.invoke(reload) + + +@cli.command() +@click.argument("service") +@click.pass_context +def delete(ctx, service: str): + """Delete a service""" + filename = service if service.endswith(".conf") else service + ".conf" + + file = NGINX_DIR / "sites/auto" / filename + if not file.exists(): + file = NGINX_DIR / "sites/custom" / filename + + if not file.exists(): + click.secho(f"Service {filename} does not exist", fg="red") + exit(1) + + c, out = test_config("", file) + if not c: + click.echo(out, err=True) + click.secho("Failed to verify nginx configuration", fg="red") + click.echo("Service was not deleted") + exit(1) + + file.unlink() + + ctx.invoke(reload) + + +@cli.command("list") +def list_(): + """List exsiting services and domain names associated with them""" + files = certs.get_site_files(NGINX_DIR) + for file in files: + config = file.read_text() + domain_names = set() + for directive in re.finditer(r"(?:^|\n\s*|[{;]\s*)server_name (.*);", config): + for domain in directive.group(1).split(): + domain_names.add(domain) + + click.echo(f"{file.name}: {' '.join(domain_names)}") + + +@cli.command() +def autossl(): + """Renew SSL certificates and replicate changes""" + # build domains.txt + try: + directives = certs.gather_autossl_directives(NGINX_DIR / "nginx.conf") + + DOMAINS_TXT.write_text(certs.build_domains_txt(directives)) + except ValueError as e: + click.secho(e, err=True, fg="red") + exit(1) + + # obtain certs + quit_on_err( + sysaction.run_dehydrated(DEHYDRATED_LOC), + additional_info="Failed to run dehydrated", + ) + + if DEHYDRATED_TRIGGER_FILE.exists(): + click.echo("Certificates changed - reloading cluster") + certs.generate_ssl_configs(NGINX_DIR / "ssl", [d[1] for d in directives]) + + # reload nginx + quit_on_err(sysaction.reload_nginx(), additional_info="Failed to reload nginx") + + # replicate to remote + sysaction.remote_replication(REMOTE, REMOTE_SSH_KEY) + quit_on_err( + sysaction.remote_check_nginx_config(REMOTE, REMOTE_SSH_KEY, NGINX_DIR), + additional_info="Remote nginx configuration is incorrect", + ) + quit_on_err( + sysaction.remote_reload_nginx(REMOTE, REMOTE_SSH_KEY), + additional_info="Remote nginx failed to reload", + ) diff --git a/nginx_configurator/sysaction.py b/nginx_configurator/sysaction.py new file mode 100644 index 0000000..0d78af2 --- /dev/null +++ b/nginx_configurator/sysaction.py @@ -0,0 +1,85 @@ +import os +from pathlib import Path +import signal +import subprocess +import time +import click +import sysrsync + + +def _run_shell(cmd): + out = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + return out.returncode == 0, out.stdout + + +def quit_on_err(out, print_always=False, additional_info=""): + if (not out[0] or print_always) and len(out[1].strip()) > 0: + # print out nginx warnings + click.echo(out[1], err=True) + + if not out[0]: + if additional_info: + click.secho(additional_info, err=True, fg="red") + exit(1) + + +def is_keepalived_master(): + keepalived_data = Path("/tmp/keepalived.data") + keepalived_data.unlink(missing_ok=True) # do not read old data + + sig = int(os.getenv("KEEPALIVED_DATA_SIGNAL", signal.SIGUSR1)) + os.kill(int(Path("/run/keepalived.pid").read_text()), sig) + + # wait for keepalived + for _ in range(10): + time.sleep(0.05) + if keepalived_data.exists(): + break + else: + raise Exception(f"keepalived did not produce data on signal {sig}") + + # TODO: could we do better? + return "State = MASTER" in Path("/tmp/keepalived.data").read_text() + + +def reload_nginx(): + return _run_shell(("nginx", "-s", "reload")) + + +def check_nginx_config(nginx_dir: Path): + return _run_shell(("nginx", "-t", "-c", str(nginx_dir / "nginx.conf"))) + + +def run_dehydrated(dehydrated_bin: str): + return _run_shell((dehydrated_bin, "-c")) + + +def remote_replication(remote, ssh_key): + # Copy nginx config to second server + sysrsync.run( + source="/etc/nginx/", + destination="/etc/nginx/", + destination_ssh=remote, + private_key=ssh_key, + options=["-a", "--delete"], + ) + # Copy certificates to second server + sysrsync.run( + source="/etc/autossl/", + destination="/etc/autossl/", + destination_ssh=remote, + private_key=ssh_key, + options=["-a", "--delete"], + ) + + +def remote_check_nginx_config(remote, ssh_key, nginx_dir: Path): + # Check and reload nginx on second server + nc = str(nginx_dir / "nginx.conf") + return _run_shell(("ssh", "-i", ssh_key, remote, "nginx", "-t", "-c", nc)) + + +def remote_reload_nginx(remote, ssh_key): + return _run_shell(("ssh", "-i", ssh_key, remote, "nginx", "-s", "reload")) diff --git a/nginx_configurator/templates/__init__.py b/nginx_configurator/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nginx_configurator/templates/nginx-minimal.conf b/nginx_configurator/templates/nginx-minimal.conf new file mode 100644 index 0000000..5884073 --- /dev/null +++ b/nginx_configurator/templates/nginx-minimal.conf @@ -0,0 +1 @@ +# ID: {{ id }} diff --git a/templates/nginx-site.conf b/nginx_configurator/templates/nginx-site.conf similarity index 82% rename from templates/nginx-site.conf rename to nginx_configurator/templates/nginx-site.conf index 0a61ab9..65ccef1 100644 --- a/templates/nginx-site.conf +++ b/nginx_configurator/templates/nginx-site.conf @@ -1,5 +1,5 @@ # ID: {{ id }} -# Service configured by n_gen.py +# Service configured by ncc upstream up_{{ id }} { {%- for upstream in upstreams %} @@ -10,8 +10,8 @@ upstream up_{{ id }} { server { server_name{% for domain in domains %} {{ domain }}{% endfor %}; # AUTOSSL > {{ id }} - listen 443 ssl http2; - listen [::]:443 ssl http2; + listen 443 ssl http2; + listen [::]:443 ssl http2; # ssl include /etc/nginx/ssl/{{ id }}.conf; @@ -27,8 +27,8 @@ server { # reverse proxy location / { - proxy_pass {{ proto }}up_{{ id }}; - include include/proxy-headers.conf; + proxy_pass {{ proto }}up_{{ id }}; + include include/proxy-headers.conf; } } @@ -46,4 +46,4 @@ server { location / { return 301 https://$host$request_uri; } -} \ No newline at end of file +} diff --git a/nginx_configurator/templates/ssl.conf b/nginx_configurator/templates/ssl.conf new file mode 100644 index 0000000..cf4920f --- /dev/null +++ b/nginx_configurator/templates/ssl.conf @@ -0,0 +1,4 @@ +ssl_certificate /etc/autossl/certs/{{ alias }}/fullchain.pem; +ssl_certificate_key /etc/autossl/certs/{{ alias }}/privkey.pem; +include include/ssl_defaults.conf; +ssl_dhparam /etc/autossl/ssl-dhparams.pem; diff --git a/nginx_configurator/templating.py b/nginx_configurator/templating.py new file mode 100644 index 0000000..58efd82 --- /dev/null +++ b/nginx_configurator/templating.py @@ -0,0 +1,15 @@ +import pkgutil +from jinja2 import Environment, FunctionLoader, select_autoescape + + +def load_template(name): + """ + Loads file from the templates folder and returns file contents as a string. + See jinja2.FunctionLoader docs. + """ + return pkgutil.get_data(f"{__package__}.templates", name).decode("utf-8") + + +jinja = Environment( + loader=FunctionLoader(load_template), autoescape=select_autoescape() +) diff --git a/requirements.txt b/requirements.txt index 3fa2610..3459908 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pyinputplus -Jinja2 +click +python-dotenv sysrsync -python-dotenv \ No newline at end of file +Jinja2 diff --git a/run.py b/run.py new file mode 100644 index 0000000..cc58bac --- /dev/null +++ b/run.py @@ -0,0 +1,3 @@ +from nginx_configurator import main + +main.cli() diff --git a/templates/ssl.conf b/templates/ssl.conf deleted file mode 100644 index 8a680ce..0000000 --- a/templates/ssl.conf +++ /dev/null @@ -1,4 +0,0 @@ -ssl_certificate /etc/autossl/certs/{{ id }}/fullchain.pem; -ssl_certificate_key /etc/autossl/certs/{{ id }}/privkey.pem; -include include/ssl_defaults.conf; -ssl_dhparam /etc/autossl/ssl-dhparams.pem; \ No newline at end of file