Ejemplo n.º 1
0
def account(config_path: str, directory_path: str):
    dnsrobocert_config = config.load(config_path)
    acme = dnsrobocert_config.get("acme", {})
    email = acme.get("email_account")

    if not email:
        LOGGER.warning(
            "Parameter acme.email_account is not set, skipping ACME registration."
        )
        return

    url = config.get_acme_url(dnsrobocert_config)

    utils.execute(
        [
            sys.executable,
            "-m",
            "dnsrobocert.core.certbot",
            "register",
            *_DEFAULT_FLAGS,
            "--config-dir",
            directory_path,
            "--work-dir",
            os.path.join(directory_path, "workdir"),
            "--logs-dir",
            os.path.join(directory_path, "logs"),
            "-m",
            email,
            "--agree-tos",
            "--server",
            url,
        ],
        check=False,
    )
Ejemplo n.º 2
0
def main(args: List[str] = None) -> int:
    if not args:
        args = sys.argv[1:]

    parser = argparse.ArgumentParser()
    parser.add_argument("-t",
                        "--type",
                        choices=["auth", "cleanup", "deploy"],
                        required=True)
    parser.add_argument("-c", "--config", required=True)
    parser.add_argument("-l", "--lineage", default="None", required=False)

    parsed_args = parser.parse_args(args)
    dnsrobocert_config = config.load(parsed_args.config)

    if not dnsrobocert_config:
        print(
            f"Error occured while loading the configuration file, aborting the `{parsed_args.type}` hook.",
            file=sys.stderr,
        )
        return 1

    try:
        globals()[parsed_args.type](dnsrobocert_config, parsed_args.lineage)
    except BaseException as e:
        print(
            f"Error while executing the `{parsed_args.type}` hook:",
            file=sys.stderr,
        )
        print(e, file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return 1

    return 0
def test_pfx(_autorestart, _autocmd, _fix_permissions, fake_env, fake_config):
    archive_path = fake_env["archive"]
    key = rsa.generate_private_key(public_exponent=65537,
                                   key_size=2048,
                                   backend=default_backend())
    with open(archive_path / "privkey.pem", "wb") as f:
        f.write(
            key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption(),
            ))

    subject = issuer = x509.Name(
        [x509.NameAttribute(NameOID.COMMON_NAME, u"example.com")])
    cert = (x509.CertificateBuilder().subject_name(subject).issuer_name(
        issuer).public_key(key.public_key()).serial_number(
            x509.random_serial_number()).not_valid_before(
                datetime.datetime.utcnow()).not_valid_after(
                    datetime.datetime.utcnow() +
                    datetime.timedelta(days=10)).sign(key, hashes.SHA256(),
                                                      default_backend()))

    with open(archive_path / "cert.pem", "wb") as f:
        f.write(cert.public_bytes(serialization.Encoding.PEM))
    with open(archive_path / "chain.pem", "wb") as f:
        f.write(cert.public_bytes(serialization.Encoding.PEM))

    hooks.deploy(config.load(fake_config), LINEAGE)

    assert os.path.exists(archive_path / "cert.pfx")
    assert os.stat(archive_path / "cert.pfx").st_size != 0
def test_fix_permissions(_autorestart, _autocmd, _pfx_export, fake_config,
                         fake_env):
    archive_path = str(fake_env["archive"])
    live_path = str(fake_env["live"])

    probe_file = os.path.join(archive_path, "dummy.txt")
    probe_dir = os.path.join(archive_path, "dummy_dir")

    probe_live_file = os.path.join(live_path, "dummy.txt")
    probe_live_dir = os.path.join(live_path, "dummy_dir")

    open(probe_file, "w").close()
    os.mkdir(probe_dir)

    with _mock_os_chown() as chown:
        hooks.deploy(config.load(fake_config), LINEAGE)

        assert os.stat(probe_file).st_mode & 0o777 == 0o666
        assert os.stat(probe_dir).st_mode & 0o777 == 0o777
        assert os.stat(archive_path).st_mode & 0o777 == 0o777

        if POSIX_MODE:
            uid = pwd.getpwnam("nobody")[2]
            gid = grp.getgrnam("nogroup")[2]

            calls = [
                call(archive_path, uid, gid),
                call(probe_file, uid, gid),
                call(probe_dir, uid, gid),
                call(live_path, uid, gid),
                call(probe_live_file, uid, gid),
                call(probe_live_dir, uid, gid),
            ]

            assert chown.call_args_list == calls
def test_autocmd(check_call, _exists, _autorestart, _pfx_export,
                 _fix_permissions, fake_config):
    hooks.deploy(config.load(fake_config), LINEAGE)

    call_foo = call(["docker", "exec", "foo", "echo 'Hello World!'"])
    call_bar = call(["docker", "exec", "bar", "echo 'Hello World!'"])
    check_call.assert_has_calls([call_foo, call_bar])
Ejemplo n.º 6
0
def _process_config(
    config_path: str,
    directory_path: str,
    runtime_config_path: str,
    lock: threading.Lock,
):
    dnsrobocert_config = config.load(config_path)

    if not dnsrobocert_config:
        return

    if dnsrobocert_config.get("draft"):
        LOGGER.info(
            "Configuration file is in draft mode: no action will be done.")
        return

    with open(runtime_config_path, "w") as f:
        f.write(yaml.dump(dnsrobocert_config))

    utils.configure_certbot_workspace(dnsrobocert_config, directory_path)

    LOGGER.info("Registering ACME account if needed.")
    certbot.account(runtime_config_path, directory_path, lock)

    LOGGER.info("Creating missing certificates if needed (~1min for each)")
    certificates = dnsrobocert_config.get("certificates", {})
    for certificate in certificates:
        try:
            lineage = config.get_lineage(certificate)
            domains = certificate["domains"]
            force_renew = certificate.get("force_renew", False)
            LOGGER.info(
                f"Handling the certificate for domain(s): {', '.join(domains)}"
            )
            certbot.certonly(
                runtime_config_path,
                directory_path,
                lineage,
                lock,
                domains,
                force_renew=force_renew,
            )
        except BaseException as error:
            LOGGER.error(
                f"An error occurred while processing certificate config `{certificate}`:\n{error}"
            )

    LOGGER.info("Revoke and delete certificates if needed")
    lineages = {
        config.get_lineage(certificate)
        for certificate in certificates
    }
    for domain in os.listdir(os.path.join(directory_path, "live")):
        if domain != "README":
            domain = re.sub(r"^\*\.", "", domain)
            if domain not in lineages:
                LOGGER.info(f"Removing the certificate {domain}")
                certbot.revoke(runtime_config_path, directory_path, domain,
                               lock)
Ejemplo n.º 7
0
def test_good_config_minimal(tmp_path):
    config_path = tmp_path / "config.yml"
    with open(str(config_path), "w") as f:
        f.write("""\
draft: true
""")

    parsed = config.load(str(config_path))
    assert parsed
Ejemplo n.º 8
0
def test_bad_config_wrong_schema(tmp_path):
    config_path = tmp_path / "config.yml"
    with open(str(config_path), "w") as f:
        f.write("""\
draft: true
wrong_property: bad
""")

    parsed = config.load(str(config_path))
    assert not parsed
Ejemplo n.º 9
0
def certonly(
    config_path: str,
    directory_path: str,
    lineage: str,
    lock: threading.Lock,
    domains: Optional[List[str]] = None,
    force_renew: bool = False,
    reuse_key: bool = False,
):
    if not domains:
        return

    url = config.get_acme_url(config.load(config_path))

    additional_params = []
    if force_renew:
        additional_params.append("--force-renew")
    if reuse_key:
        additional_params.append("--reuse-key")

    for domain in domains:
        additional_params.append("-d")
        additional_params.append(domain)

    utils.execute(
        [
            sys.executable,
            "-m",
            "dnsrobocert.core.certbot",
            "certonly",
            *_DEFAULT_FLAGS,
            "--config-dir",
            directory_path,
            "--work-dir",
            os.path.join(directory_path, "workdir"),
            "--logs-dir",
            os.path.join(directory_path, "logs"),
            "--manual",
            "--preferred-challenges=dns",
            "--manual-auth-hook",
            _hook_cmd("auth", config_path, lineage),
            "--manual-cleanup-hook",
            _hook_cmd("cleanup", config_path, lineage),
            "--expand",
            "--deploy-hook",
            _hook_cmd("deploy", config_path, lineage),
            "--server",
            url,
            "--cert-name",
            lineage,
            *additional_params,
        ],
        lock=lock,
    )
Ejemplo n.º 10
0
def test_bad_config_wrong_posix_mode(tmp_path):
    config_path = tmp_path / "config.yml"
    with open(str(config_path), "w") as f:
        f.write("""\
draft: true
acme:
  certs_permissions:
    files_mode: 9999
""")

    parsed = config.load(str(config_path))
    assert not parsed
Ejemplo n.º 11
0
def test_environment_variable_injection(tmp_path):
    config_path = tmp_path / "config.yml"
    with open(str(config_path), "w") as f:
        f.write("""\
draft: ${DRAFT_VALUE}
acme:
  certs_root_path: $${NOT_PARSED}
profiles:
- name: one
  provider: ${PROVIDER}
certificates:
- name: test.example.com
  domains: [test1.example.com, test2.example.com, '${ADDITIONAL_CERT}']
  profile: one
""")

    environ = os.environ.copy()
    try:
        os.environ.update({
            "DRAFT_VALUE": "true",
            "PROVIDER": "one",
            "ADDITIONAL_CERT": "test3.example.com",
        })
        parsed = config.load(str(config_path))
    finally:
        os.environ.clear()
        os.environ.update(environ)

    assert parsed["draft"] is True
    assert parsed["acme"]["certs_root_path"] == "${NOT_PARSED}"
    assert parsed["profiles"][0]["provider"] == "one"
    assert "test3.example.com" in parsed["certificates"][0]["domains"]

    with pytest.raises(ValueError) as raised:
        config.load(str(config_path))

    assert (
        str(raised.value) ==
        "Error while parsing config: environment variable DRAFT_VALUE does not exist."
    )
def test_autorestart(check_call, _exists, _autocmd, _pfx_export,
                     _fix_permissions, fake_config):
    hooks.deploy(config.load(fake_config), LINEAGE)

    call_container1 = call(["docker", "restart", "container1"])
    call_container2 = call(["docker", "restart", "container2"])
    call_service1 = call([
        "docker", "service", "update", "--detach=false", "--force", "service1"
    ])
    call_service2 = call([
        "docker", "service", "update", "--detach=false", "--force", "service2"
    ])
    check_call.assert_has_calls(
        [call_container1, call_container2, call_service1, call_service2])
Ejemplo n.º 13
0
def test_bad_config_non_existent_profile(tmp_path):
    config_path = tmp_path / "config.yml"
    with open(str(config_path), "w") as f:
        f.write("""\
draft: true
profiles:
- name: one
  provider: one
certificates:
- domains: [test.example.com]
  profile: two
""")

    parsed = config.load(str(config_path))
    assert not parsed
Ejemplo n.º 14
0
def test_bad_config_duplicated_cert_name(tmp_path):
    config_path = tmp_path / "config.yml"
    with open(str(config_path), "w") as f:
        f.write("""\
draft: true
profiles:
- name: one
  provider: one
certificates:
- domains: [test.example.com]
  profile: one
- name: test.example.com
  domains: [test1.example.com, test2.example.com]
  profile: one
""")

    parsed = config.load(str(config_path))
    assert not parsed
Ejemplo n.º 15
0
def renew(config_path: str, directory_path: str):
    dnsrobocert_config = config.load(config_path)

    if dnsrobocert_config:
        utils.execute([
            sys.executable,
            "-m",
            "dnsrobocert.core.certbot",
            "renew",
            *_DEFAULT_FLAGS,
            "--config-dir",
            directory_path,
            "--deploy-hook",
            _hook_cmd("deploy", config_path),
            "--work-dir",
            os.path.join(directory_path, "workdir"),
            "--logs-dir",
            os.path.join(directory_path, "logs"),
        ])
Ejemplo n.º 16
0
def revoke(config_path: str, directory_path: str, lineage: str):
    url = config.get_acme_url(config.load(config_path))

    utils.execute([
        sys.executable,
        "-m",
        "dnsrobocert.core.certbot",
        "revoke",
        "-n",
        "--config-dir",
        directory_path,
        "--work-dir",
        os.path.join(directory_path, "workdir"),
        "--logs-dir",
        os.path.join(directory_path, "logs"),
        "--server",
        url,
        "--cert-path",
        os.path.join(directory_path, "live", lineage, "cert.pem"),
    ])
Ejemplo n.º 17
0
def test_legacy_migration(tmp_path, monkeypatch):
    config_path = tmp_path / "dnsrobocert" / "config.yml"
    legacy_config_domain_file = tmp_path / "old_config" / "domains.conf"
    generated_config_path = tmp_path / "dnsrobocert" / "config-generated.yml"
    os.mkdir(os.path.dirname(legacy_config_domain_file))

    with open(
            os.path.join(os.path.dirname(legacy_config_domain_file),
                         "lexicon.yml"), "w") as f:
        f.write("""\
ovh:
  auth_application_secret: SECRET
  additional_config: ADDITIONAL
""")

    with open(
            os.path.join(os.path.dirname(legacy_config_domain_file),
                         "lexicon_ovh.yml"), "w") as f:
        f.write("""\
auth_consumer_key: CONSUMER_KEY
""")

    with open(str(legacy_config_domain_file), "w") as f:
        f.write("""\
test1.sub.example.com test2.sub.example.com autorestart-containers=container1,container2 autocmd-containers=container3:cmd3 arg3,container4:cmd4 arg4a arg4b
*.sub.example.com sub.example.com
""")

    monkeypatch.setenv("LEXICON_PROVIDER", "ovh")
    monkeypatch.setenv("LEXICON_OVH_AUTH_APPLICATION_KEY", "KEY")
    monkeypatch.setenv("LEXICON_OPTIONS", "--delegated=sub.example.com")
    monkeypatch.setenv(
        "LEXICON_PROVIDER_OPTIONS",
        "--auth-entrypoint ovh-eu --auth-application-secret=SECRET-OVERRIDE",
    )
    monkeypatch.setenv("LEXICON_SLEEP_TIME", "60")
    monkeypatch.setenv("LEXICON_MAX_CHECKS", "3")
    monkeypatch.setenv("LEXICON_TTL", "42")
    monkeypatch.setenv("LETSENCRYPT_USER_MAIL", "*****@*****.**")
    monkeypatch.setenv("LETSENCRYPT_STAGING", "true")
    monkeypatch.setenv("LETSENCRYPT_ACME_V1", "true")
    monkeypatch.setenv("CERTS_DIRS_MODE", "0755")
    monkeypatch.setenv("CERTS_FILES_MODE", "0644")
    monkeypatch.setenv("CERTS_USER_OWNER", "nobody")
    monkeypatch.setenv("CERTS_GROUP_OWNER", "nogroup")
    monkeypatch.setenv("PFX_EXPORT", "true")
    monkeypatch.setenv("PFX_EXPORT_PASSPHRASE", "PASSPHRASE")
    monkeypatch.setenv("DEPLOY_HOOK", "./deploy.sh")

    with mock.patch(
            "dnsrobocert.core.legacy.LEGACY_CONFIGURATION_PATH",
            new=legacy_config_domain_file,
    ):
        legacy.migrate(config_path)

    assert config.load(generated_config_path)
    with open(generated_config_path) as f:
        generated_data = f.read()

    assert (generated_data == """\
acme:
  api_version: 1
  certs_permissions:
    dirs_mode: 493
    files_mode: 420
    group: nogroup
    user: nobody
  email_account: [email protected]
  staging: true
certificates:
- autocmd:
  - cmd: cmd3 arg3
    containers:
    - container3
  - cmd: cmd4 arg4a arg4b
    containers:
    - container4
  autorestart:
  - containers:
    - container1
    - container2
  deploy_hook: ./deploy.sh
  domains:
  - test1.sub.example.com
  - test2.sub.example.com
  name: test1.sub.example.com
  pfx:
    export: true
    passphrase: PASSPHRASE
  profile: ovh
- deploy_hook: ./deploy.sh
  domains:
  - '*.sub.example.com'
  - sub.example.com
  name: sub.example.com
  pfx:
    export: true
    passphrase: PASSPHRASE
  profile: ovh
profiles:
- delegated_subdomain: sub.example.com
  max_checks: 3
  name: ovh
  provider: ovh
  provider_options:
    additional_config: ADDITIONAL
    auth_application_key: KEY
    auth_application_secret: SECRET-OVERRIDE
    auth_consumer_key: CONSUMER_KEY
    auth_entrypoint: ovh-eu
  sleep_time: 60
  ttl: 42
""")
def test_deploy_cli(deploy, fake_config):
    hooks.main(["-t", "deploy", "-c", str(fake_config), "-l", LINEAGE])
    deploy.assert_called_with(config.load(fake_config), LINEAGE)