diff --git a/config/acme/montagu.yml b/config/acme/montagu.yml index c6cc386..2651d83 100644 --- a/config/acme/montagu.yml +++ b/config/acme/montagu.yml @@ -1,3 +1,19 @@ +vault: + ## Address of the vault server. This should be a string if it is + ## present. + addr: ~ + auth: + ## Authentication type - must be either "token" or the name of a + ## supported authentication method. These seem to be poorly + ## documented in the hvac, but include "github" for github + ## authentication. + ## + ## On a vault client object, see auth.implemented_class_names for + ## a list, which is currently + ## + ## azure, github, gcp, kubernetes, ldap, mfa, okta + method: token + ## Prefix for container names; we'll use {container_prefix}-(container_name) container_prefix: montagu @@ -14,7 +30,7 @@ repo: vimc network: montagu-network # Domain where this instance of Montagu will be deployed. E.g. science.montagu.dide.ic.uk -hostname: montagu.org +hostname: localhost ## Names of the docker volumes to use volumes: @@ -24,9 +40,7 @@ volumes: templates: template_volume guidance: guidance_volume mq: mq - acme-challenge: acme-challenge - certificates: certificates - certbot: certbot + montagu-tls: montagu-tls api: name: montagu-api @@ -76,10 +90,17 @@ proxy: repo: nginx name: nginx-prometheus-exporter tag: 1.3.0 - acme: - email: admin@montagu.org - additional_domains: - - montagu-dev.org +acme_buddy: + email: admin@montagu.org + additional_domains: + - montagu-dev.org + repo: ghcr.io/reside-ic + name: acme-buddy + tag: main + hdb_username: VAULT:secret/certbot-hdb/credentials:username + hdb_password: VAULT:secret/certbot-hdb/credentials:password + port: 2112 + contrib: name: montagu-contrib-portal tag: master diff --git a/config/complete/montagu.yml b/config/complete/montagu.yml index ab02359..4c07c56 100644 --- a/config/complete/montagu.yml +++ b/config/complete/montagu.yml @@ -83,17 +83,6 @@ proxy: repo: nginx name: nginx-prometheus-exporter tag: 1.3.0 - ## This section describes how to get the certificate in. We - ## support two sources: - ## - ## 1. self signed certificates - just leave this section blank - ## - ## 2. certificates from strings - include the strings directly in - ## the keys here, or more likely use a VAULT:: - ## string to extract them from the vault. - ssl: - key: "k3y" - certificate: "cert" contrib: name: montagu-contrib-portal tag: master diff --git a/src/montagu_deploy/__about__.py b/src/montagu_deploy/__about__.py index b101341..0a73d74 100644 --- a/src/montagu_deploy/__about__.py +++ b/src/montagu_deploy/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present Alex # # SPDX-License-Identifier: MIT -__version__ = "0.0.9" +__version__ = "0.1.0" diff --git a/src/montagu_deploy/certbot.py b/src/montagu_deploy/certbot.py deleted file mode 100644 index 199b282..0000000 --- a/src/montagu_deploy/certbot.py +++ /dev/null @@ -1,90 +0,0 @@ -# https://github.com/certbot/certbot/blob/v3.0.1/acme/examples/http01_example.py - -import os.path -import sys -import tarfile -from tempfile import TemporaryFile - -import docker -from constellation import docker_util - -# The Docker API uses Go's FileMode values. These are different from the -# standard values, as found in eg. stat.S_IFLNK. -# https://pkg.go.dev/io/fs#FileMode -DOCKER_MODE_TYPE = 0x8F280000 -DOCKER_MODE_SYMLINK = 0x8000000 - - -def read_file(container, path, *, follow_links=False): - stream, status = container.get_archive(path) - if follow_links and (status["mode"] & DOCKER_MODE_TYPE) == DOCKER_MODE_SYMLINK: - return read_file(container, status["linkTarget"], follow_links=False) - else: - with TemporaryFile() as f: - for d in stream: - f.write(d) - f.seek(0) - - with tarfile.open(fileobj=f) as tar: - return tar.extractfile(os.path.basename(path)).read() - - -def obtain_certificate(cfg, extra_args): - docker_util.ensure_volume(cfg.volumes["certbot"]) - docker_util.ensure_volume(cfg.volumes["acme-challenge"]) - - environment = {} - command = [ - "certonly", - "--non-interactive", - "--agree-tos", - "--webroot", - "--webroot-path=/var/www", - f"--email={cfg.acme_email}", - f"--domain={cfg.hostname}", - ] - - for d in cfg.acme_additional_domains: - command.append(f"--domain={d}") - - if cfg.acme_server: - command.append(f"--server={cfg.acme_server}"), - if cfg.acme_no_verify_ssl: - command.append("--no-verify-ssl") - environment["PYTHONWARNINGS"] = "ignore:Unverified HTTPS request" - - command.extend(extra_args) - - image = "certbot/certbot" - container = docker.from_env().containers.run( - image, - command=command, - detach=True, - volumes={ - cfg.volumes["acme-challenge"]: { - "bind": "/var/www/.well-known/acme-challenge", - "mode": "rw", - }, - cfg.volumes["certbot"]: { - "bind": "/etc/letsencrypt", - "mode": "rw", - }, - }, - network=cfg.network, - environment=environment, - ) - - try: - exit_status = container.wait()["StatusCode"] - - sys.stderr.write(container.logs().decode("utf-8")) - if exit_status != 0: - raise docker.errors.ContainerError(container, exit_status, command, image, None) - - cert = read_file(container, f"/etc/letsencrypt/live/{cfg.hostname}/fullchain.pem", follow_links=True) - key = read_file(container, f"/etc/letsencrypt/live/{cfg.hostname}/privkey.pem", follow_links=True) - - return (cert, key) - - finally: - container.remove() diff --git a/src/montagu_deploy/cli.py b/src/montagu_deploy/cli.py index 7495971..4c6b4a5 100644 --- a/src/montagu_deploy/cli.py +++ b/src/montagu_deploy/cli.py @@ -5,7 +5,6 @@ montagu status [--name=PATH] montagu stop [--name=PATH] [--volumes] [--network] [--kill] [--force] [--extra=PATH] [--option=OPTION]... - montagu renew-certificate [--name=PATH] [--option=OPTION]... [--] [ARGS...] Options: --name=PATH Override the configured name, use with care! @@ -26,9 +25,8 @@ import yaml import montagu_deploy.__about__ as about -from montagu_deploy.certbot import obtain_certificate from montagu_deploy.config import MontaguConfig -from montagu_deploy.montagu_constellation import montagu_constellation, proxy_update_certificate +from montagu_deploy.montagu_constellation import montagu_constellation def main(argv=None): @@ -49,8 +47,6 @@ def main(argv=None): montagu_status(obj) elif args.action == "stop": montagu_stop(obj, args, cfg) - elif args.action == "renew-certificate": - montagu_renew_certificate(obj, cfg, args.extra_args) def parse_args(argv=None): @@ -90,18 +86,6 @@ def montagu_status(obj): obj.status() -def montagu_renew_certificate(obj, cfg, extra_args): - if cfg.ssl_mode != "acme": - msg = "Proxy is not configured to use automatic certificates" - raise Exception(msg) - - print("Renewing certificates") - (cert, key) = obtain_certificate(cfg, extra_args) - - container = obj.containers.get("proxy", cfg.container_prefix) - proxy_update_certificate(container, cert, key, reload=True) - - def montagu_stop(obj, args, cfg): if args.volumes: verify_data_loss(cfg) @@ -164,8 +148,6 @@ def __init__(self, args): self.action = "status" elif args["stop"]: self.action = "stop" - elif args["renew-certificate"]: - self.action = "renew-certificate" elif args["configure"]: self.action = "configure" @@ -174,7 +156,6 @@ def __init__(self, args): self.volumes = args["--volumes"] self.network = args["--network"] self.version = args["--version"] - self.extra_args = args["ARGS"] IDENTITY_FILE = Path(".montagu_identity") diff --git a/src/montagu_deploy/config.py b/src/montagu_deploy/config.py index fd31916..47b447e 100644 --- a/src/montagu_deploy/config.py +++ b/src/montagu_deploy/config.py @@ -56,21 +56,14 @@ def __init__(self, path, extra=None, options=None): self.proxy_port_metrics = config.config_integer(dat, ["proxy", "port_metrics"], is_optional=True, default=9000) self.proxy_metrics_ref = self.build_ref(dat["proxy"], "metrics") - if "ssl" in dat["proxy"] and "acme" in dat["proxy"]: - msg = "Cannot specify both ssl and acme options in proxy options." - raise Exception(msg) - if "ssl" in dat["proxy"]: - self.ssl_mode = "static" - self.ssl_certificate = config.config_string(dat, ["proxy", "ssl", "certificate"]) - self.ssl_key = config.config_string(dat, ["proxy", "ssl", "key"]) - elif "acme" in dat["proxy"]: + if "acme_buddy" in dat: self.ssl_mode = "acme" - self.acme_email = config.config_string(dat, ["proxy", "acme", "email"]) - self.acme_server = config.config_string(dat, ["proxy", "acme", "server"], is_optional=True) - self.acme_no_verify_ssl = config.config_boolean(dat, ["proxy", "acme", "no_verify_ssl"], is_optional=True) - self.acme_additional_domains = config.config_list( - dat, ["proxy", "acme", "additional_domains"], is_optional=True, default=[] - ) + self.acme_buddy_ref = self.build_ref(dat, "acme_buddy") + self.acme_buddy_port = config.config_integer(dat, ["acme_buddy", "port"]) + self.acme_buddy_hdb_username = config.config_string(dat, ["acme_buddy", "hdb_username"]) + self.acme_buddy_hdb_password = config.config_string(dat, ["acme_buddy", "hdb_password"]) + self.acme_buddy_email = config.config_string(dat, ["acme_buddy", "email"]) + self.acme_additional_domains = config.config_list(dat, ["acme_buddy", "additional_domains"]) else: self.ssl_mode = "self-signed" @@ -106,6 +99,9 @@ def __init__(self, path, extra=None, options=None): if self.fake_smtp_ref: self.containers["fake_smtp"] = "fake-smtp" + if self.ssl_mode == "acme": + self.containers["acme-buddy"] = "acme-buddy" + self.images = { "db": self.db_ref, "api": self.api_ref, @@ -123,6 +119,9 @@ def __init__(self, path, extra=None, options=None): if self.fake_smtp_ref: self.images["fake_smtp"] = self.fake_smtp_ref + if self.ssl_mode == "acme": + self.images["acme-buddy"] = self.acme_buddy_ref + def build_ref(self, dat, section): name = config.config_string(dat, [section, "name"]) tag = config.config_string(dat, [section, "tag"]) diff --git a/src/montagu_deploy/montagu_constellation.py b/src/montagu_deploy/montagu_constellation.py index 127fb12..75614c8 100644 --- a/src/montagu_deploy/montagu_constellation.py +++ b/src/montagu_deploy/montagu_constellation.py @@ -1,21 +1,26 @@ +import os from os.path import join import constellation import docker import yaml -from constellation import docker_util +from constellation import docker_util, vault from psycopg2 import connect from montagu_deploy import database def montagu_constellation(cfg): + if cfg.vault and cfg.vault.url: + vault.resolve_secrets(cfg, cfg.vault.client()) + + proxy = proxy_container(cfg) containers = [ api_container(cfg), db_container(cfg), admin_container(cfg), contrib_container(cfg), - proxy_container(cfg), + proxy, proxy_metrics_container(cfg), mq_container(cfg), flower_container(cfg), @@ -26,6 +31,10 @@ def montagu_constellation(cfg): fake_smtp = fake_smtp_container(cfg) containers.append(fake_smtp) + if cfg.ssl_mode == "acme": + acme_buddy = acme_buddy_container(cfg, proxy) + containers.append(acme_buddy) + return constellation.Constellation( "montagu", cfg.container_prefix, containers, cfg.network, cfg.volumes, data=cfg, vault_config=cfg.vault ) @@ -92,6 +101,47 @@ def fake_smtp_container(cfg): return constellation.ConstellationContainer(name, cfg.fake_smtp_ref, ports=[1025, 1080]) +def acme_buddy_container(cfg, proxy): + name = cfg.containers["acme-buddy"] + acme_buddy_staging = int(os.environ.get("ACME_BUDDY_STAGING", "0")) + acme_env = { + "ACME_BUDDY_STAGING": acme_buddy_staging, + "HDB_ACME_USERNAME": cfg.acme_buddy_hdb_username, + "HDB_ACME_PASSWORD": cfg.acme_buddy_hdb_password, + } + acme_mounts = [ + constellation.ConstellationVolumeMount("montagu-tls", "/tls"), + constellation.ConstellationBindMount("/var/run/docker.sock", "/var/run/docker.sock"), + ] + + domain_names = ",".join([cfg.hostname] + cfg.acme_additional_domains) + + acme = constellation.ConstellationContainer( + name, + cfg.acme_buddy_ref, + ports=[cfg.acme_buddy_port], + mounts=acme_mounts, + environment=acme_env, + args=[ + "--domain", + domain_names, + "--email", + cfg.acme_buddy_email, + "--dns-provider", + "hdb", + "--certificate-path", + "/tls/certificate.pem", + "--key-path", + "/tls/key.pem", + "--account-path", + "/tls/account.json", + "--reload-container", + proxy.name_external(cfg.container_prefix), + ], + ) + return acme + + def db_container(cfg): name = cfg.containers["db"] mounts = [constellation.ConstellationVolumeMount("db", "/pgdata")] @@ -215,10 +265,7 @@ def proxy_container(cfg): if cfg.ssl_mode == "acme": mounts.extend( [ - constellation.ConstellationVolumeMount( - "acme-challenge", "/var/www/.well-known/acme-challenge", read_only=True - ), - constellation.ConstellationVolumeMount("certificates", "/etc/montagu/proxy"), + constellation.ConstellationVolumeMount("montagu-tls", "/etc/montagu/proxy"), ] ) @@ -227,31 +274,10 @@ def proxy_container(cfg): cfg.proxy_ref, ports=proxy_ports, args=[str(cfg.proxy_port_https), cfg.hostname], - preconfigure=proxy_preconfigure, mounts=mounts, ) -def proxy_update_certificate(container, cert, key, *, reload): - print("[proxy] Copying ssl certificate and key into proxy") - ssl_path = "/etc/montagu/proxy" - docker_util.string_into_container(cert, container, join(ssl_path, "certificate.pem")) - docker_util.string_into_container(key, container, join(ssl_path, "ssl_key.pem")) - - if reload: - print("[proxy] Reloading nginx") - docker_util.exec_safely(container, "nginx -s reload") - - -def proxy_preconfigure(container, cfg): - # In self-signed mode, the container generates its own certificate on its - # own. Similarly, in ACME mode, the container generates its own certificate - # and after starting we request a new one. - if cfg.ssl_mode == "static": - print("[proxy] Configuring reverse proxy") - proxy_update_certificate(container, cfg.ssl_certificate, cfg.ssl_key, reload=False) - - def proxy_metrics_container(cfg): proxy_name = cfg.containers["proxy"] return constellation.ConstellationContainer( diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a14df4..0bac3a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,7 +60,7 @@ def test_parse_args(): def test_version(capsys): cli.main(["--version"]) - out, err = capsys.readouterr() + out, _err = capsys.readouterr() assert re.match(r"\d+\.\d+\.\d+", out) @@ -104,16 +104,6 @@ def test_args_passed_to_configure(): assert f.mock_calls[0] == mock.call("config/basic") -def test_can_parse_extra_certbot_args(): - res = cli.parse_args(["renew-certificate", "--name=config/basic", "--", "--force-renewal"]) - assert res[0] == "config/basic" - assert res[1] is None - assert res[2] == [] - args = res[3] - assert args.action == "renew-certificate" - assert args.extra_args == ["--force-renewal"] - - def test_verify_data_loss_called(): f = io.StringIO() with redirect_stdout(f): diff --git a/tests/test_config.py b/tests/test_config.py index 2a515c7..5d63560 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -76,18 +76,10 @@ def test_config_email(): assert cfg.email_flow_url == "fakeurl" -def test_config_ssl(): - cfg = MontaguConfig("config/complete") - assert cfg.ssl_mode == "static" - assert cfg.ssl_certificate == "cert" - assert cfg.ssl_key == "k3y" - - def test_config_acme(): cfg = MontaguConfig("config/acme") assert cfg.ssl_mode == "acme" - assert cfg.acme_email == "admin@montagu.org" - assert cfg.acme_server is None + assert cfg.acme_buddy_email == "admin@montagu.org" def test_config_generates_root_db_password(): @@ -109,5 +101,5 @@ def test_config_streaming_replication(): def test_config_validates_db_user_permissions(): options = {"db": {"users": {"api": {"permissions": "bad", "password": "pw"}}}} - with pytest.raises(Exception, match="Invalid database permissions for 'api'."): + with pytest.raises(Exception, match=r"Invalid database permissions for 'api'."): MontaguConfig("config/basic", options=options) diff --git a/tests/test_constellation.py b/tests/test_constellation.py index 857c1aa..49e6c7e 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -124,23 +124,6 @@ def test_db_configured(): obj.stop(kill=True, remove_volumes=True) -def test_proxy_configured_ssl(): - cfg = MontaguConfig("config/complete") - obj = montagu_constellation(cfg) - - try: - obj.start() - - api = get_container(cfg, "proxy") - cert = docker_util.string_from_container(api, "/etc/montagu/proxy/certificate.pem") - key = docker_util.string_from_container(api, "/etc/montagu/proxy/ssl_key.pem") - assert cert == "cert" - assert key == "k3y" - - finally: - obj.stop(kill=True, remove_volumes=True) - - def test_metrics(): cfg = MontaguConfig("config/basic") obj = montagu_constellation(cfg) diff --git a/tests/test_integration.py b/tests/test_integration.py index 3eff69e..9fe23ec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,18 +1,12 @@ import os -import ssl -import time from unittest import mock import celery import docker -import pytest import requests import tenacity import vault_dev from constellation import docker_util -from cryptography import x509 -from cryptography.hazmat.primitives import hashes -from cryptography.x509.oid import ExtensionOID from packit_deploy.config import PackitConfig from packit_deploy.packit_constellation import PackitConstellation from YTClient.YTClient import YTClient @@ -21,7 +15,7 @@ from src.montagu_deploy import cli from src.montagu_deploy.config import MontaguConfig from tests import admin -from tests.utils import http_get, run_pebble +from tests.utils import http_get def test_start_stop_status(): @@ -158,73 +152,41 @@ def add_task_queue_user(cfg, packit): admin.add_role_to_user(cfg, "task.queue", "user") -def test_acme_certificate(): - path = "config/acme" - network = "montagu-network" - - try: - options = [ - "--option=proxy.acme.server=https://pebble/dir", - "--option=proxy.acme.no_verify_ssl=true", - ] - - cli.main(["start", "--name", path, *options]) - - # wait for nginx to be ready - http_get("https://localhost") - - # We need Pebble to be able to resolve the proxy at the names used in the certificate. - # We set this up by adding a custom /etc/hosts in the pebble container pointing to the - # right IP address. - container = docker.from_env().containers.get("montagu-proxy") - ip = container.attrs["NetworkSettings"]["Networks"][network]["IPAddress"] - hostnames = { - "montagu.org": ip, - "montagu-dev.org": ip, - } - - with run_pebble(hostname="pebble", network=network, extra_hosts=hostnames): - # Initially the server starts with a self-signed certificate. - # This allows it to start even before we get our first cert. - cert_pem = ssl.get_server_certificate(("localhost", 443)) - cert = x509.load_pem_x509_certificate(cert_pem.encode("ascii")) - assert cert.issuer == cert.subject - self_signed_fingerprint = cert.fingerprint(hashes.SHA256()) - - # Renew the certificate using ACME. Confirm that it worked by - # looking at the issuer's CN. It can take some time for nginx to - # reload, so loop until the certificate has changed. - cli.main(["renew-certificate", "--name", path, *options]) - - for _ in range(5): - cert_pem = ssl.get_server_certificate(("localhost", 443)) - cert = x509.load_pem_x509_certificate(cert_pem.encode("ascii")) - time.sleep(0.5) - if cert.fingerprint(hashes.SHA256()) != self_signed_fingerprint: - break - else: - pytest.fail("Certificate was not reloaded") - - acme_fingerprint = cert.fingerprint(hashes.SHA256()) - assert "CN=Pebble Intermediate CA" in cert.issuer.rfc4514_string() - san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) - assert set(san.value.get_values_for_type(x509.DNSName)) == { - "montagu.org", - "montagu-dev.org", - } - - # When restarting the server, the certificate we got from ACME is - # carried over and is immediately available, no need to issue it - # again. - cli.main(["stop", "--name", path, "--kill"]) - cli.main(["start", "--name", path, *options]) - - cert_pem = ssl.get_server_certificate(("localhost", 443)) - cert = x509.load_pem_x509_certificate(cert_pem.encode("ascii")) - assert "CN=Pebble Intermediate CA" in cert.issuer.rfc4514_string() - assert cert.fingerprint(hashes.SHA256()) == acme_fingerprint - - finally: - with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: - prompt.return_value = True - cli.main(["stop", "--name", path, "--kill", "--volumes", "--network"]) +def test_acme(): + with vault_dev.Server(export_token=True) as s: + try: + path = "config/acme" + cl = s.client() + cl.write("secret/certbot-hdb/credentials", username="hdb-us3r", password="hdb-p@assword") + vault_addr = f"http://localhost:{s.port}" + cli.main( + [ + "start", + "--name", + path, + f"--option=vault.auth.args.token={s.token}", + f"--option=vault.addr={vault_addr}", + ] + ) + client = docker.client.from_env() + container = client.containers.get("montagu-acme-buddy") + env = container.attrs["Config"]["Env"] + env_dict = dict(e.split("=", 1) for e in env) + assert "hdb-us3r" in env_dict["HDB_ACME_USERNAME"] + assert "hdb-p@assword" in env_dict["HDB_ACME_PASSWORD"] + + finally: + with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: + prompt.return_value = True + cli.main( + [ + "stop", + "--name", + path, + f"--option=vault.auth.args.token={s.token}", + f"--option=vault.addr={vault_addr}", + "--kill", + "--volumes", + "--network", + ] + )