rework and refactor

This commit is contained in:
bain 2024-11-04 01:05:25 +01:00
parent 71babd685b
commit 7b36581fd0
Signed by: bain
GPG key ID: 31F0F25E3BED0B9B
19 changed files with 553 additions and 540 deletions

3
ncc
View file

@ -1,3 +0,0 @@
#!/bin/env python3
from nginx_configurator import main
main.cli()

8
ncc.yml.sample Normal file
View file

@ -0,0 +1,8 @@
conf_dir: "test-conf"
main_config: "nginx.conf"
dehydrated_dir: "dehydrated"
sites_dir: "sites"
targets:
- "ssh-host:/path/to/deployment"
- "/path/to/local/deployment"

3
ncc/__main__.py Normal file
View file

@ -0,0 +1,3 @@
from .main import cli
cli.main()

131
ncc/certs.py Normal file
View file

@ -0,0 +1,131 @@
import glob
import os
import re
import sys
from collections import deque
from collections.abc import Generator
from pathlib import Path
from string import Template
from typing import Tuple
from .error import NccException
SSL_CONFIG_TEMPLATE = """
ssl_certificate_key $keypath;
ssl_certificate $certpath;
""".lstrip()
SERVER_BLOCK_RE = re.compile(r"(?:^|[{};])\s*server\s*{", re.MULTILINE)
INCLUDE_RE = re.compile(r"(?:^|[{};])\s*include\s+([^;]+);", re.MULTILINE)
SERVER_NAME_RE = re.compile(r"(?:^|[{};])\s*server_name\s+([^;]+);", re.MULTILINE)
class ConfigError(Exception):
pass
def _config_files(main_cfg: Path) -> Generator[Tuple[Path, str]]:
cfg = main_cfg
text = cfg.read_text()
yield (cfg, text)
for include in re.finditer(INCLUDE_RE, text):
for file in glob.glob(include.group(1)):
for cfg in _config_files(Path(file)):
yield cfg
def _remove_comments(cfg: str) -> str:
"""I haven't actually read the parser, so we might be discarding more than we should, oh well"""
output = []
for line in cfg.splitlines():
output.append(line.split("#")[0])
return "\n".join(output)
def _get_server_blocks(cfg: str) -> Generator[str]:
for server_block_start in re.finditer(SERVER_BLOCK_RE, cfg):
start_idx = idx = server_block_start.end()
brackets = 1
while brackets > 0 and idx < len(cfg):
if cfg[idx] == "{":
brackets += 1
elif cfg[idx] == "}":
brackets -= 1
idx += 1
yield cfg[start_idx : idx - 1]
def get_sites(cfg: Path) -> Generator[Tuple[Path, str]]:
"""Expects to be in the conf dir"""
for c in _config_files(cfg):
config_part = _remove_comments(c[1])
for server_block in _get_server_blocks(config_part):
sn = next(re.finditer(SERVER_NAME_RE, server_block))
if not sn:
continue
domains = sn.group(1).split()
for domain in domains:
yield (c[0], domain)
def generate_ssl(cfg: Path, domainstxt_file: Path) -> int:
"""Expects to be in the conf dir"""
to_generate = {}
os.makedirs("genssl/", exist_ok=True)
os.makedirs(domainstxt_file.parent, exist_ok=True)
for c in _config_files(cfg):
config_part = _remove_comments(c[1])
for server_block in _get_server_blocks(config_part):
sn = next(re.finditer(SERVER_NAME_RE, server_block))
if not sn:
continue
wants_ssl = next(
(
i.group(1)
for i in INCLUDE_RE.finditer(server_block)
if i.group(1).startswith("genssl/")
),
None,
)
domains = sn.group(1).split()
domains.sort()
if wants_ssl:
if (entry := to_generate.get(wants_ssl)) and entry != domains:
raise NccException(
f'config requests "{wants_ssl}" with different domain sets'
)
else:
to_generate[wants_ssl] = domains
domainstxt = []
for file, domains in to_generate.items():
file_no_prefix = file.removeprefix("genssl/")
Path(file).write_text(
Template(SSL_CONFIG_TEMPLATE).substitute(
{
"keypath": domainstxt_file.parent
/ f"certs/{file_no_prefix}/privkey.pem",
"certpath": domainstxt_file.parent
/ f"certs/{file_no_prefix}/fullchain.pem",
}
)
)
domainstxt.append(" ".join(domains) + " > " + file_no_prefix + "\n")
domainstxt_file.write_text("".join(domainstxt))
return 0

13
ncc/error.py Normal file
View file

@ -0,0 +1,13 @@
import click
import sys
class NccException(click.ClickException):
def __init__(self, *args, log=None):
super().__init__(*args)
self.log = log
def show(self, file=None) -> None:
if self.log:
click.echo(self.log)
click.secho("ERROR: " + self.format_message(), file or sys.stderr, fg="red")

264
ncc/main.py Normal file
View file

@ -0,0 +1,264 @@
import os
import tempfile
from collections import defaultdict
from pathlib import Path
import click
import sysrsync
import yaml
from . import certs, sysaction, templating
from .error import NccException
CONFIG = {
"main_config": "nginx.conf",
"conf_dir": ".",
"targets": [],
"dehydrated_dir": "dehydrated",
"sites_dir": "sites",
}
class Step:
"""Fancy printing of steps with option to hide the details after it finishes"""
def __init__(self, text, hide=False):
self.text = text
self.lines = 1
self.on_start = True
self.hide = hide
def echo(self, message, *args, **kwargs) -> None:
nl = int(kwargs.get("nl", 1))
self.lines += message.count("\n") + nl
if self.on_start:
click.echo(" => ", nl=False)
self.on_start = nl or (bool(message) and message[-1] == "\n")
click.echo("\n => ".join(message.splitlines()), *args, **kwargs)
def __enter__(self) -> "Step":
click.secho("[ ] " + self.text)
return self
def __exit__(self, exc, _exc2, _exc3) -> None:
click.echo(
f'\r\x1b[{self.lines}F[{click.style("K" if not exc else "E", fg="green" if not exc else "red")}'
)
if self.hide and not exc:
click.echo("\x1b[0J", nl=False)
elif self.lines > 1:
click.echo(f"\x1b[{self.lines-1}E", nl=False)
def load_config():
cwd = Path(os.getcwd())
conf_file = None
root = Path(cwd.root)
while cwd != root:
conf_file = cwd / "ncc.yml"
if conf_file.exists():
os.chdir(cwd)
break
else:
conf_file = None
cwd = cwd.parent
if not conf_file:
click.echo(
"Failed to find ncc configuration (searched all parent folders)", err=True
)
exit(1)
with conf_file.open() as f:
if yml := yaml.safe_load(f):
CONFIG.update(yml)
os.chdir(CONFIG["conf_dir"])
@click.group()
def cli():
"""Update the nginx cluster configuration
MUST BE RAN ON MASTER (will detect automatically)
"""
load_config()
@cli.command()
@click.option(
"--dehydrated-only",
type=bool,
is_flag=True,
help="Only fetches and deploys new certificates",
)
@click.option("--skip-master-check", type=bool, is_flag=True)
def up(dehydrated_only: bool, skip_master_check: bool):
"""Deploy the configuration to the cluster
Does the following:
1. generates ssl config in genssl folder
2. runs dehydrated
3. checks the configuration
4. deploys to the cluster
"""
if not skip_master_check and not sysaction.is_keepalived_master():
click.echo("Refusing to start. Not running on master.", err=True)
exit(1)
with Step("Preparing configuration..."):
dehydrated_dir = Path(CONFIG["dehydrated_dir"])
if not dehydrated_only:
certs.generate_ssl(
Path(CONFIG["main_config"]),
Path(CONFIG["dehydrated_dir"]) / "domains.txt",
)
ec, stdout = sysaction.run_shell(
(str(dehydrated_dir / "dehydrated.sh"), "-c"), window_height=5
)
if ec != 0:
log = Path(tempfile.mktemp())
log.write_text(stdout)
raise NccException(
f"dehydrated returned {ec} (log: {click.format_filename(log)})"
)
ec, stdout = sysaction.run_shell(
("nginx", "-t", "-p", ".", "-c", CONFIG["main_config"]), window_height=5
)
if ec != 0:
raise NccException("configuration did not pass nginx test", log=stdout)
with Step("Deploying to cluster...") as step:
for target in CONFIG["targets"]:
step.echo('deploying to "' + target + '"', nl=False)
try:
sysrsync.run(
source=os.getcwd(),
destination=target,
exclusions=[str(dehydrated_dir / "archive/**")],
options=["-a", "--delete"],
)
except Exception as e:
step.echo(click.style(" E", fg="red"))
raise NccException("failed to rsync configuration") from e
# technically we could be misinterpreting a path here...
if (first_part := target.split("/")[0]) and (
remote := first_part.split(":")[0]
):
ec, stdout = sysaction.run_shell(("ssh", "-T", remote, "echo 1"))
else:
ec, stdout = sysaction.run_shell(("echo", "1"))
if ec != 0:
step.echo(click.style(" E", fg="red"))
raise NccException("failed to reload nginx", log=stdout)
step.echo(click.style(" K", fg="green"))
@cli.command()
def test():
"""Run nginx -t on the configuration"""
ec, stdout = sysaction.run_shell(
("nginx", "-t", "-p", ".", "-c", CONFIG["main_config"])
)
click.echo(stdout, nl=False)
exit(ec)
def shell_complete_service(_ctx, _param, incomplete):
load_config()
return [
site
for _, site in certs.get_sites(Path(CONFIG["main_config"]))
if incomplete in site
]
@cli.command()
@click.argument("site", shell_complete=shell_complete_service)
def edit(site: str):
"""Edit a site"""
file = next(
(f for f, s in certs.get_sites(Path(CONFIG["main_config"])) if site == s), None
)
if not file or not file.exists():
raise NccException("could not find site")
click.edit(filename=str(file))
@cli.command("list")
def list_():
"""List all sites and the files they are located in"""
file_sites = defaultdict(list)
for f, s in certs.get_sites(Path(CONFIG["main_config"])):
file_sites[f].append(s)
for file, sites in file_sites.items():
click.echo(str(file) + ": " + " ".join(sites))
@cli.command()
def new():
"""Create a new site"""
sites_dir = Path(CONFIG["sites_dir"])
if not sites_dir.exists():
raise NccException("sites_dir does not exist")
templates = [f.name for f in Path("templates").glob("*.conf")]
templates.sort()
printed = len(templates)
for i, t in enumerate(templates):
click.echo(f" {i+1}) {t}")
while True:
try:
printed += 1
choice = int(input("Template number: ")) - 1
except ValueError:
continue
if 0 <= choice < len(templates):
break
click.echo(f"\r\x1b[{printed}F\x1b[0J", nl=False)
click.secho(f"=== {templates[choice]}", bold=True)
name, filled = templating.fill_template(
Path(f"templates/{templates[choice]}").read_text()
)
if not name:
name = input("File name (without .conf): ").removesuffix(".conf")
while not name or (file := sites_dir / (name + ".conf")).exists():
if name and click.confirm(
f"File {click.format_filename(file)} already exists. Overwrite?"
):
break
name = input("File name (without .conf): ").removesuffix(".conf")
file.write_text(filled)
click.echo(f"Site written to {click.format_filename(file)}")
if click.confirm("Continue editing?"):
click.edit(filename=str(file))

64
ncc/sysaction.py Normal file
View file

@ -0,0 +1,64 @@
import os
import signal
import subprocess
import sys
import time
from collections import deque
from pathlib import Path
import click
import sysrsync
def run_shell(cmd, window_height=0):
out = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
stdout = []
if window_height:
h = window_height
try:
buf = deque()
for line in out.stdout:
term_size = os.get_terminal_size()
prefixed = (" => " + line).rstrip()[: term_size[0] - 1] + "\n"
stdout.append(line)
buf.append(prefixed)
if h != 0:
sys.stdout.write(prefixed)
sys.stdout.flush()
h -= 1
continue
buf.popleft()
sys.stdout.write(f"\x1b[{window_height}F\x1b[0J")
sys.stdout.write("".join(buf))
sys.stdout.flush()
finally:
if h < window_height:
sys.stdout.write(f"\x1b[{window_height-h}F\x1b[0J")
sys.stdout.flush()
out.wait()
return out.returncode, "".join(stdout) if stdout else out.stdout.read()
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()

52
ncc/templating.py Normal file
View file

@ -0,0 +1,52 @@
import bisect
import re
from collections import defaultdict
from typing import Optional, Tuple
IDENTIFIER_RE = re.compile(r"(?<!%)(?:%(\w+)\b|%{(\w+)})")
def fill_template(template_str: str) -> Tuple[Optional[str], str]:
lines = template_str.splitlines()
identifier_contexts = defaultdict(list)
for i, line in enumerate(lines):
for match in re.finditer(IDENTIFIER_RE, line):
name = match[1] or match[2]
for j in range(max(0, i - 2), min(len(lines) - 1, i + 2)):
ctx = identifier_contexts[name]
idx = bisect.bisect_left(ctx, j)
if idx >= len(ctx) or ctx[idx] != j:
ctx.insert(idx, j)
replacements = {}
for identifier, ctx in identifier_contexts.items():
def colorize(match):
name = match[1] or match[2]
if name == identifier:
return f"\x1b[31m%{match[1] or match[2]}\x1b[0m"
else:
return match[0]
prev = 0
printed = 0
for line_idx in ctx:
print(re.sub(IDENTIFIER_RE, colorize, lines[line_idx]))
printed += 1
if prev and line_idx - prev > 1:
print("\x1b[1m---\x1b[0m")
printed += 1
prev = line_idx
replacements[identifier] = input("\x1b[1m => " + identifier + ":\x1b[0m ")
printed += 1
print(f"\r\x1b[{printed}F\x1b[0J", end="")
def replace(match):
return replacements[match[1] or match[2]]
return replacements.get("name"), re.sub(IDENTIFIER_RE, replace, template_str).replace("%%","%")

View file

@ -1,82 +0,0 @@
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").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))

View file

@ -1,353 +0,0 @@
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_BIN = os.getenv("DEHYDRATED_BIN", "dehydrated")
DEHYDRATED_TRIGGER_FILE = Path(
os.getenv("DEHYDRATED_TRIGGER_FILE", "/tmp/ncc-ssl-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_BIN),
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" / 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:
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
if not ok:
Path(f"/etc/nginx/ssl/{id}.conf").unlink(missing_ok=True)
exit(1)
service_file.write_text(config)
ctx.invoke(reload)
def shell_complete_service(ctx, param, incomplete):
return [file.name for file in certs.get_site_files(NGINX_DIR) if incomplete in file.name]
@cli.command()
@click.argument("service", shell_complete=shell_complete_service)
@click.pass_context
def edit(ctx, service: str):
"""Edit a service"""
filename = service if service.endswith(".conf") else service + ".conf"
file = NGINX_DIR / "sites" / 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)
file.write_text(new_cfg)
ctx.invoke(reload)
@cli.command()
@click.argument("service", shell_complete=shell_complete_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" / 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_BIN),
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",
)

View file

@ -1,85 +0,0 @@
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"))

View file

@ -1,15 +0,0 @@
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()
)

View file

@ -1,4 +1,3 @@
click
python-dotenv
sysrsync
Jinja2
pyyaml

17
setup.py Normal file
View file

@ -0,0 +1,17 @@
from setuptools import setup
setup(
name='ncc',
version='1.0.0',
py_modules=['ncc'],
install_requires=[
'click',
'sysrsync',
'pyyaml',
],
entry_points={
'console_scripts': [
'ncc = ncc.main:cli',
],
},
)