diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8b4e55..eed7c2d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,12 +49,12 @@ jobs: docker pull vimc/montagu-db:master docker pull vimc/montagu-migrate:master docker pull vimc/montagu-reverse-proxy:master - docker pull vimc/montagu-reverse-proxy:vimc-7152 docker pull vimc/orderly-web-user-cli:master docker pull vimc/orderly-web:master docker pull vimc/orderly.server:master docker pull vimc/orderlyweb-migrate:master docker pull vimc/task-queue-worker:master + docker pull ghcr.io/letsencrypt/pebble:latest - name: Test env: VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }} diff --git a/README.md b/README.md index 368f51c..13a2fef 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,7 @@ pip install montagu-deploy ## Usage ``` -$ montagu --help -Usage: - montagu --version - montagu start [--extra=PATH] [--option=OPTION]... [--pull] - montagu status - montagu stop [--volumes] [--network] [--kill] [--force] - [--extra=PATH] [--option=OPTION]... - -Options: - --extra=PATH Path, relative to , of yml file of additional - configuration - --option=OPTION Additional configuration options, in the form key=value - Use dots in key for hierarchical structure, e.g., a.b=value - This argument may be repeated to provide multiple arguments - --pull Pull images before starting - --volumes Remove volumes (WARNING: irreversible data loss) - --network Remove network - --kill Kill the containers (faster, but possible db corruption) - --force Force stop even if containers are corrupted and cannot - signal their running configuration, or if config cannot be - parsed. Use with extra and/or option to force stop with - configuration options. +$ montagu start ``` Here `` is the path to a directory that contains a configuration file `montagu.yml`. diff --git a/config/acme/diagnostic-reports.yml b/config/acme/diagnostic-reports.yml new file mode 100644 index 0000000..851fa6d --- /dev/null +++ b/config/acme/diagnostic-reports.yml @@ -0,0 +1,9 @@ +testGroup: + testDisease: + - report_name: diagnostic + assignee: a.hill + success_email: + recipients: + - minimal_modeller@example.com + - science@example.com + subject: "VIMC diagnostic report: {touchstone} - {group} - {disease}" diff --git a/config/acme/montagu.yml b/config/acme/montagu.yml new file mode 100644 index 0000000..5fa619b --- /dev/null +++ b/config/acme/montagu.yml @@ -0,0 +1,125 @@ +## Prefix for container names; we'll use {container_prefix}-(container_name) +container_prefix: montagu + +## Set this flag to true to prevent use of --volumes in the cli to remove +## volumes on stop +protect_data: false + +## Docker org for images +repo: vimc + +## The name of the docker network that containers will be attached to. +## If you want to proxy Packit to the host, you will need to +## arrange a proxy on this network +network: montagu-network + +# Domain where this instance of Montagu will be deployed. E.g. science.montagu.dide.ic.uk +hostname: montagu.org + +## Names of the docker volumes to use +volumes: + db: db_volume + burden_estimates: burden_estimate_files + emails: emails + templates: template_volume + guidance: guidance_volume + mq: mq + acme-challenge: acme-challenge + certificates: certificates + certbot: certbot + +api: + name: montagu-api + tag: master + admin: + name: montagu-cli + tag: master +db: + name: montagu-db + tag: master + root_user: vimc + migrate: + name: montagu-migrate + tag: master + users: + api: + password: "apipassword" + permissions: all + import: + password: "importpassword" + permissions: all + orderly: + password: "orderlypassword" + permissions: all + readonly: + password: "readonlypassword" + permissions: readonly + protected_tables: + - gavi_support_level + - activity_type + - burden_outcome + - gender + - responsibility_set_status + - impact_outcome + - gavi_support_level + - support_type + - touchstone_status + - permission + - role + - role_permission +proxy: + name: montagu-reverse-proxy + tag: master + port_http: 80 + port_https: 443 + metrics: + repo: nginx + name: nginx-prometheus-exporter + tag: 1.3.0 + acme: + email: admin@montagu.org + additional_domains: + - montagu-dev.org +contrib: + name: montagu-contrib-portal + tag: master +admin: + name: montagu-admin-portal + tag: master +mq: + repo: docker.io + name: redis + tag: latest + port: 6379 +flower: + repo: mher + name: flower + tag: 0.9.5 + port: 5555 +task_queue: + name: task-queue-worker + tag: master + tasks: + diagnostic_reports: + use_additional_recipients: false + poll_seconds: 5 + archive_folder_contents: + min_file_age_seconds: 3600 + servers: + youtrack: + token: faketoken + orderlyweb: + url: http://orderly-web-web:8888 + montagu: + user: montagu-task@imperial.ac.uk + password: password + smtp: + from: montagu-notifications@imperial.ac.uk +# If fake_smtp_server config is provided, the task_queue will use this as its smtp server +# Note this will override other config provided in the task_queue section above +fake_smtp_server: + repo: reachfive + name: fake-smtp-server + tag: latest + +orderly_web_api_url: https://localhost/reports/api/v2 diff --git a/config/basic/montagu.yml b/config/basic/montagu.yml index e517529..196b81d 100644 --- a/config/basic/montagu.yml +++ b/config/basic/montagu.yml @@ -66,7 +66,7 @@ db: - role_permission proxy: name: montagu-reverse-proxy - tag: vimc-7152 + tag: master port_http: 80 port_https: 443 metrics: diff --git a/config/ci/montagu.yml b/config/ci/montagu.yml index 7e00495..f48a208 100644 --- a/config/ci/montagu.yml +++ b/config/ci/montagu.yml @@ -83,7 +83,7 @@ db: - role_permission proxy: name: montagu-reverse-proxy - tag: vimc-7152 + tag: master port_http: 80 port_https: 443 metrics: diff --git a/config/complete/montagu.yml b/config/complete/montagu.yml index 22a7853..4502d31 100644 --- a/config/complete/montagu.yml +++ b/config/complete/montagu.yml @@ -94,7 +94,6 @@ proxy: ssl: key: "k3y" certificate: "cert" - dhparam: "param" contrib: name: montagu-contrib-portal tag: master diff --git a/pyproject.toml b/pyproject.toml index a290cf9..8e7fb7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,8 @@ dependencies = [ "pytest", "redis", "vault_dev", - "YTClient" + "YTClient", + "cryptography" ] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" diff --git a/src/montagu_deploy/certbot.py b/src/montagu_deploy/certbot.py new file mode 100644 index 0000000..199b282 --- /dev/null +++ b/src/montagu_deploy/certbot.py @@ -0,0 +1,90 @@ +# 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 072456f..f01d492 100644 --- a/src/montagu_deploy/cli.py +++ b/src/montagu_deploy/cli.py @@ -4,6 +4,7 @@ montagu status montagu stop [--volumes] [--network] [--kill] [--force] [--extra=PATH] [--option=OPTION]... + montagu renew-certificate [--option=OPTION]... [--] [ARGS...] Options: --extra=PATH Path, relative to , of yml file of additional @@ -15,24 +16,21 @@ --volumes Remove volumes (WARNING: irreversible data loss) --network Remove network --kill Kill the containers (faster, but possible db corruption) - --force Force stop even if containers are corrupted and cannot - signal their running configuration, or if config cannot be - parsed. Use with extra and/or option to force stop with - configuration options. """ import docopt 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 +from montagu_deploy.montagu_constellation import montagu_constellation, proxy_update_certificate def main(argv=None): path, extra, options, args = parse_args(argv) if args.version: - return about.__version__ + print(about.__version__) else: cfg = MontaguConfig(path, extra, options) obj = montagu_constellation(cfg) @@ -42,7 +40,8 @@ def main(argv=None): montagu_status(obj) elif args.action == "stop": montagu_stop(obj, args, cfg) - return True + elif args.action == "renew-certificate": + montagu_renew_certificate(obj, cfg, args.extra_args) def parse_args(argv=None): @@ -61,6 +60,18 @@ 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) @@ -123,9 +134,12 @@ def __init__(self, args): self.action = "status" elif args["stop"]: self.action = "stop" + elif args["renew-certificate"]: + self.action = "renew-certificate" self.pull = args["--pull"] self.kill = args["--kill"] self.volumes = args["--volumes"] self.network = args["--network"] self.version = args["--version"] + self.extra_args = args["ARGS"] diff --git a/src/montagu_deploy/config.py b/src/montagu_deploy/config.py index 368279c..307cf6d 100644 --- a/src/montagu_deploy/config.py +++ b/src/montagu_deploy/config.py @@ -13,14 +13,7 @@ def __init__(self, path, extra=None, options=None): self.vault = config.config_vault(dat, ["vault"]) self.network = config.config_string(dat, ["network"]) self.protect_data = config.config_boolean(dat, ["protect_data"]) - self.volumes = { - "db": config.config_string(dat, ["volumes", "db"]), - "emails": config.config_string(dat, ["volumes", "emails"]), - "burden_estimates": config.config_string(dat, ["volumes", "burden_estimates"]), - "templates": config.config_string(dat, ["volumes", "templates"]), - "guidance": config.config_string(dat, ["volumes", "guidance"]), - "mq": config.config_string(dat, ["volumes", "mq"]), - } + self.volumes = config.config_dict(dat, ["volumes"]) self.container_prefix = config.config_string(dat, ["container_prefix"]) self.repo = config.config_string(dat, ["repo"]) @@ -58,15 +51,28 @@ def __init__(self, path, extra=None, options=None): # Proxy self.proxy_ref = self.build_ref(dat, "proxy") - self.proxy_ssl_self_signed = "ssl" not in dat["proxy"] - if not self.proxy_ssl_self_signed: - self.ssl_certificate = config.config_string(dat, ["proxy", "ssl", "certificate"]) - self.ssl_key = config.config_string(dat, ["proxy", "ssl", "key"]) - self.dhparam = config.config_string(dat, ["proxy", "ssl", "dhparam"]) self.proxy_port_http = config.config_integer(dat, ["proxy", "port_http"]) self.proxy_port_https = config.config_integer(dat, ["proxy", "port_https"]) 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"]: + 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=[] + ) + else: + self.ssl_mode = "self-signed" + # Portals self.admin_ref = self.build_ref(dat, "admin") self.contrib_ref = self.build_ref(dat, "contrib") diff --git a/src/montagu_deploy/montagu_constellation.py b/src/montagu_deploy/montagu_constellation.py index ca7653e..d5d8683 100644 --- a/src/montagu_deploy/montagu_constellation.py +++ b/src/montagu_deploy/montagu_constellation.py @@ -209,27 +209,47 @@ def inject_api_config(container, cfg): def proxy_container(cfg): name = cfg.containers["proxy"] proxy_ports = [cfg.proxy_port_http, cfg.proxy_port_https] + + mounts = [] + + if cfg.ssl_mode == "acme": + mounts.extend( + [ + constellation.ConstellationMount( + "acme-challenge", "/var/www/.well-known/acme-challenge", read_only=True + ), + constellation.ConstellationMount("certificates", "/etc/montagu/proxy"), + ] + ) + return constellation.ConstellationContainer( name, cfg.proxy_ref, ports=proxy_ports, args=[str(cfg.proxy_port_https), cfg.hostname], - configure=proxy_configure, + preconfigure=proxy_preconfigure, + mounts=mounts, ) -def proxy_configure(container, cfg): - print("[proxy] Configuring reverse proxy") +def proxy_update_certificate(container, cert, key, *, reload): + print("[proxy] Copying ssl certificate and key into proxy") ssl_path = "/etc/montagu/proxy" - if cfg.proxy_ssl_self_signed: - print("[proxy] Generating self-signed certificates for proxy") - docker_util.exec_safely(container, ["self-signed-certificate", ssl_path]) - else: - print("[proxy] Copying ssl certificate and key into proxy") - docker_util.exec_safely(container, f"mkdir -p {ssl_path}") - docker_util.string_into_container(cfg.ssl_certificate, container, join(ssl_path, "certificate.pem")) - docker_util.string_into_container(cfg.ssl_key, container, join(ssl_path, "ssl_key.pem")) - docker_util.string_into_container(cfg.dhparam, container, join(ssl_path, "dhparam.pem")) + 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): diff --git a/tests/test_cli.py b/tests/test_cli.py index b343149..5bb0ba5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import io +import re from contextlib import redirect_stdout from unittest import mock @@ -44,9 +45,10 @@ def test_parse_args(): assert args.version is True -def test_version(): - res = cli.main(["--version"]) - assert res == "0.0.5" +def test_version(capsys): + cli.main(["--version"]) + out, err = capsys.readouterr() + assert re.match(r"\d+\.\d+\.\d+", out) def test_args_passed_to_start(): @@ -81,6 +83,16 @@ def test_args_passed_to_stop(): assert f.call_args[0][1].volumes is True +def test_can_parse_extra_certbot_args(): + res = cli.parse_args(["renew-certificate", "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 869e5dc..9e688e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,7 +34,7 @@ def test_config_basic(): assert str(cfg.images["admin"]) == "vimc/montagu-admin-portal:master" assert str(cfg.images["api_admin"]) == "vimc/montagu-cli:master" assert str(cfg.images["contrib"]) == "vimc/montagu-contrib-portal:master" - assert str(cfg.images["proxy"]) == "vimc/montagu-reverse-proxy:vimc-7152" + assert str(cfg.images["proxy"]) == "vimc/montagu-reverse-proxy:master" assert str(cfg.images["mq"]) == "docker.io/redis:latest" assert str(cfg.images["flower"]) == "mher/flower:0.9.5" assert str(cfg.images["task_queue"]) == "vimc/task-queue-worker:master" @@ -46,7 +46,7 @@ def test_config_basic(): assert cfg.flower_port == 5555 assert cfg.protect_data is False - assert cfg.proxy_ssl_self_signed is True + assert cfg.ssl_mode == "self-signed" assert cfg.db_root_user == "vimc" assert len(cfg.db_root_password) == 50 @@ -76,10 +76,16 @@ def test_config_email(): def test_config_ssl(): cfg = MontaguConfig("config/complete") - assert cfg.proxy_ssl_self_signed is False + assert cfg.ssl_mode == "static" assert cfg.ssl_certificate == "cert" assert cfg.ssl_key == "k3y" - assert cfg.dhparam == "param" + + +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 def test_config_generates_root_db_password(): diff --git a/tests/test_constellation.py b/tests/test_constellation.py index 2cf37d8..857c1aa 100644 --- a/tests/test_constellation.py +++ b/tests/test_constellation.py @@ -90,10 +90,8 @@ def test_proxy_configured_self_signed(): 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") - param = docker_util.string_from_container(api, "/etc/montagu/proxy/dhparam.pem") assert cert is not None assert key is not None - assert param is not None res = http_get("https://localhost") assert "Montagu" in res @@ -136,10 +134,8 @@ def test_proxy_configured_ssl(): 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") - param = docker_util.string_from_container(api, "/etc/montagu/proxy/dhparam.pem") assert cert == "cert" assert key == "k3y" - assert param == "param" finally: obj.stop(kill=True, remove_volumes=True) diff --git a/tests/test_integration.py b/tests/test_integration.py index 0c81bd9..b34b316 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,27 +1,32 @@ import os +import ssl +import time from unittest import mock import celery import docker import orderly_web +import pytest import requests 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 YTClient.YTClient import YTClient from YTClient.YTDataClasses import Command from src.montagu_deploy import cli from src.montagu_deploy.config import MontaguConfig from tests import admin -from tests.utils import http_get +from tests.utils import http_get, run_pebble def test_start_stop_status(): path = "config/basic" try: # Start - res = cli.main(["start", path, "--pull"]) - assert res + cli.main(["start", path]) cl = docker.client.from_env() containers = cl.containers.list() @@ -32,8 +37,7 @@ def test_start_stop_status(): assert docker_util.volume_exists(cfg.volumes["db"]) # Status - res = cli.main(["status", "config/basic"]) - assert res + cli.main(["status", "config/basic"]) # Stop with mock.patch("src.montagu_deploy.cli.prompt_yes_no") as prompt: @@ -106,3 +110,75 @@ def add_task_queue_user(cfg, orderly_config_path): orderly_web.admin.grant( orderly_config_path, "montagu-task@imperial.ac.uk", ["*/reports.run", "*/reports.review", "*/reports.read"] ) + + +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", 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", 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", path, "--kill"]) + cli.main(["start", 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", path, "--kill", "--volumes", "--network"]) diff --git a/tests/utils.py b/tests/utils.py index abe7f4b..e539572 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,11 +1,16 @@ +import json import ssl import time import urllib +from contextlib import contextmanager + +import docker +from constellation import docker_util # Because we wait for a go signal to come up, we might not be able to # make the request right away: -def http_get(url, retries=5, poll=0.5): +def http_get(url, retries=10, poll=0.5): ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE @@ -18,3 +23,41 @@ def http_get(url, retries=5, poll=0.5): time.sleep(poll) error = e raise error + + +# Create a container, as a context manager. +# This container will be stopped and removed when the context manager exits. +@contextmanager +def create_container(image, **kwargs): + container = docker.from_env().containers.create(image, detach=True, **kwargs) + try: + yield container + finally: + container.stop() + container.remove() + + +# Run the Pebble ACME server. +@contextmanager +def run_pebble(**kwargs): + env = { + "PEBBLE_WFE_NONCEREJECT": 0, + "PEBBLE_VA_NOSLEEP": 1, + } + config = { + "pebble": { + "listenAddress": "0.0.0.0:443", + # These are baked into the docker image already + "certificate": "test/certs/localhost/cert.pem", + "privateKey": "test/certs/localhost/key.pem", + # This is the port pebble connects to to fetch well-known challenges + "httpPort": 80, + } + } + + with create_container( + "ghcr.io/letsencrypt/pebble:latest", command=["-config", "/config.json"], environment=env, **kwargs + ) as container: + docker_util.string_into_container(json.dumps(config), container, "/config.json") + container.start() + yield