2023-11-04 23:02:34 +01:00
|
|
|
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
|
|
|
|
|
2023-11-05 14:47:28 +01:00
|
|
|
load_dotenv(os.getenv("DOTENV_PATH", "/etc/ncc/env"))
|
2023-11-04 23:02:34 +01:00
|
|
|
|
|
|
|
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")
|
2023-11-05 14:47:28 +01:00
|
|
|
DEHYDRATED_BIN = os.getenv("DEHYDRATED_BIN", "dehydrated")
|
2023-11-04 23:02:34 +01:00
|
|
|
DEHYDRATED_TRIGGER_FILE = Path(
|
2023-11-05 14:47:28 +01:00
|
|
|
os.getenv("DEHYDRATED_TRIGGER_FILE", "/tmp/ncc-ssl-trigger")
|
2023-11-04 23:02:34 +01:00
|
|
|
)
|
|
|
|
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(
|
2023-11-05 14:47:28 +01:00
|
|
|
sysaction.run_dehydrated(DEHYDRATED_BIN),
|
2023-11-04 23:02:34 +01:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
2023-11-04 23:34:10 +01:00
|
|
|
def shell_complete_service(ctx, param, incomplete):
|
|
|
|
return [file.name for file in certs.get_site_files(NGINX_DIR) if incomplete in file.name]
|
|
|
|
|
2023-11-04 23:02:34 +01:00
|
|
|
@cli.command()
|
2023-11-04 23:34:10 +01:00
|
|
|
@click.argument("service", shell_complete=shell_complete_service)
|
2023-11-04 23:02:34 +01:00
|
|
|
@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()
|
2023-11-04 23:34:10 +01:00
|
|
|
@click.argument("service", shell_complete=shell_complete_service)
|
2023-11-04 23:02:34 +01:00
|
|
|
@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(
|
2023-11-05 14:47:28 +01:00
|
|
|
sysaction.run_dehydrated(DEHYDRATED_BIN),
|
2023-11-04 23:02:34 +01:00
|
|
|
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",
|
|
|
|
)
|