rewrite
This commit is contained in:
parent
ef0ab2752b
commit
8d87c063cd
17 changed files with 577 additions and 314 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -6,3 +6,5 @@ clusters.json
|
|||
/nginx
|
||||
/autossl
|
||||
.env
|
||||
ncc
|
||||
/venv
|
||||
|
|
8
build.sh
Executable file
8
build.sh
Executable file
|
@ -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"
|
166
n_gen.py
166
n_gen.py
|
@ -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()
|
124
n_ssl.py
124
n_ssl.py
|
@ -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()
|
|
@ -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
|
0
nginx_configurator/__init__.py
Normal file
0
nginx_configurator/__init__.py
Normal file
85
nginx_configurator/certs.py
Normal file
85
nginx_configurator/certs.py
Normal file
|
@ -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))
|
364
nginx_configurator/main.py
Normal file
364
nginx_configurator/main.py
Normal file
|
@ -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",
|
||||
)
|
85
nginx_configurator/sysaction.py
Normal file
85
nginx_configurator/sysaction.py
Normal file
|
@ -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"))
|
0
nginx_configurator/templates/__init__.py
Normal file
0
nginx_configurator/templates/__init__.py
Normal file
1
nginx_configurator/templates/nginx-minimal.conf
Normal file
1
nginx_configurator/templates/nginx-minimal.conf
Normal file
|
@ -0,0 +1 @@
|
|||
# ID: {{ id }}
|
|
@ -1,5 +1,5 @@
|
|||
# ID: {{ id }}
|
||||
# Service configured by n_gen.py
|
||||
# Service configured by ncc
|
||||
|
||||
upstream up_{{ id }} {
|
||||
{%- for upstream in upstreams %}
|
4
nginx_configurator/templates/ssl.conf
Normal file
4
nginx_configurator/templates/ssl.conf
Normal file
|
@ -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;
|
15
nginx_configurator/templating.py
Normal file
15
nginx_configurator/templating.py
Normal file
|
@ -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()
|
||||
)
|
|
@ -1,4 +1,4 @@
|
|||
pyinputplus
|
||||
Jinja2
|
||||
sysrsync
|
||||
click
|
||||
python-dotenv
|
||||
sysrsync
|
||||
Jinja2
|
||||
|
|
3
run.py
Normal file
3
run.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from nginx_configurator import main
|
||||
|
||||
main.cli()
|
|
@ -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;
|
Loading…
Reference in a new issue